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:
Antigravity AgentandClaude Opus 5.5 committed 2026-10-09 07:34:09 +02:00
1 parent 03e57e4308
commit 360f3cf012
38 files changed
+2557 -1242

No files matched your search

+81 -34
View File
@@ -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]);
}