mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-13 10:19:09 +02:00
feat(web): unified reader with reading settings, faster lists, a11y
- New Reader (desktop + mobile): loads full content on open, prefetches the next article, reading progress and time, "Aa" settings (light / sepia / dark / auto theme, serif / sans / Atkinson Hyperlegible font, size, line height, measure), Space-to-next, keyboard shortcuts, focus management, inert background, URL-driven (?a=) so Back closes it. - Dashboard: cursor pagination, unread/all filter, CSS grid (no masonry), no re-sorting under the reader, optimistic cache patches instead of refetch storms, "new articles" banner, error/offline states, j/k/o/m/s/v shortcuts and help dialog. - Cards: keyboard-openable, lazy <img>, memoized, touch-only swipe. - Focus mode: framer drag (drop @use-gesture), Read button, undo restores unread state, arrow keys. - WebSocket: single connection, backoff, cache patching, no console logs. - Contrast fixes (on-nature tokens), 44px targets, lang="fr", reduced motion, self-hosted fonts, theme applied before first paint. - Code splitting + vendor chunk, prompt-based PWA updates, smaller precache, offline cache for opened articles and images, caches cleared on logout; upgrade react-router (CVE), drop unused deps and assets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03e57e4308
commit
360f3cf012
38 files changed
+2557
-1242
No files matched your search
@@ -1,51 +1,98 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { create } from 'zustand';
|
||||
import { patchArticle } from './useArticleActions';
|
||||
|
||||
interface LiveState {
|
||||
/** Articles fetched since the list was last loaded (not yet shown). */
|
||||
newCount: number;
|
||||
addNew: (n: number) => void;
|
||||
clearNew: () => void;
|
||||
}
|
||||
|
||||
export const useLive = create<LiveState>((set) => ({
|
||||
newCount: 0,
|
||||
addNew: (n) => set((s) => ({ newCount: s.newCount + n })),
|
||||
clearNew: () => set({ newCount: 0 }),
|
||||
}));
|
||||
|
||||
interface WsEvent {
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps one WebSocket per session. Server events only concern the current
|
||||
* user; read/favorite/summary updates patch the cache in place, new articles
|
||||
* are announced (not auto-inserted) so the list never jumps while reading.
|
||||
*/
|
||||
export function useWebsocket() {
|
||||
const queryClient = useQueryClient();
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/api/v1/ws`;
|
||||
let socket: WebSocket | null = null;
|
||||
let retry = 0;
|
||||
let retryTimer: number | undefined;
|
||||
let feedsTimer: number | undefined;
|
||||
let closed = false;
|
||||
|
||||
const refreshFeedsSoon = () => {
|
||||
window.clearTimeout(feedsTimer);
|
||||
feedsTimer = window.setTimeout(() => qc.invalidateQueries({ queryKey: ['feeds'] }), 1500);
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
let msg: WsEvent;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const p = msg.payload ?? {};
|
||||
switch (msg.type) {
|
||||
case 'article_updated': {
|
||||
const { id, ...rest } = p as { id: string } & Record<string, unknown>;
|
||||
if (id) patchArticle(qc, id, rest);
|
||||
if ('is_read' in rest) refreshFeedsSoon();
|
||||
break;
|
||||
}
|
||||
case 'new_articles':
|
||||
useLive.getState().addNew(Number(p.count) || 0);
|
||||
refreshFeedsSoon();
|
||||
break;
|
||||
case 'articles_bulk_read':
|
||||
qc.invalidateQueries({ queryKey: ['articles'] });
|
||||
refreshFeedsSoon();
|
||||
break;
|
||||
case 'refresh_done':
|
||||
refreshFeedsSoon();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const socket = new WebSocket(wsUrl);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('WS Message:', data);
|
||||
|
||||
if (data.type === 'new_articles' || data.type === 'article_updated') {
|
||||
// Invalidate articles and feeds query to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WS message', err);
|
||||
}
|
||||
if (closed) return;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(`${protocol}//${window.location.host}/api/v1/ws`);
|
||||
socket.onopen = () => {
|
||||
retry = 0;
|
||||
};
|
||||
|
||||
socket.onmessage = onMessage;
|
||||
socket.onclose = () => {
|
||||
console.log('WS connection closed, retrying in 5s...');
|
||||
setTimeout(connect, 5000);
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
console.error('WS error:', err);
|
||||
socket.close();
|
||||
if (closed) return;
|
||||
const delay = Math.min(30_000, 1000 * 2 ** retry++);
|
||||
retryTimer = window.setTimeout(connect, delay);
|
||||
};
|
||||
socket.onerror = () => socket?.close();
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
}
|
||||
closed = true;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearTimeout(feedsTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}, [queryClient]);
|
||||
}, [qc]);
|
||||
}
|
||||
Reference in new issue
Block a user