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

+106 -98
View File
@@ -1,23 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { feedsApi } from '../api/feeds';
import { ApiError } from '../api/client';
interface AddFeedModalProps {
isOpen: boolean;
onClose: () => void;
}
export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
export function AddFeedModal({ onClose }: AddFeedModalProps) {
const [url, setUrl] = useState('');
const [importResult, setImportResult] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
const queryClient = useQueryClient();
const addFeedMutation = useMutation({
mutationFn: (u: string) => feedsApi.add({ url: u }),
mutationFn: (u: string) => feedsApi.add({ url: u.trim() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
setUrl('');
onClose();
},
});
@@ -26,108 +26,116 @@ export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
mutationFn: (file: File) => feedsApi.importOPML(file),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
queryClient.invalidateQueries({ queryKey: ['articles'] });
window.alert(`${res.imported} flux importé(s), ${res.skipped} ignoré(s).`);
onClose();
setImportResult(
`${res.imported} flux importé${res.imported > 1 ? 's' : ''}, ${res.skipped} ignoré${res.skipped > 1 ? 's' : ''}.`,
);
},
});
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
previousFocus.current = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
return () => {
window.removeEventListener('keydown', onKey);
previousFocus.current?.focus?.();
};
}, [onClose]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addFeedMutation.mutate(url);
};
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
};
const addError =
addFeedMutation.error instanceof ApiError && addFeedMutation.error.status === 409
? 'Vous êtes déjà abonné à ce flux.'
: 'Échec de l’ajout. Vérifiez l’adresse du flux.';
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="add-feed-title"
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 animate-fade-in"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="add-feed-title"
>
<div className="w-full max-w-md surface-card p-6 sm:p-8 animate-rise" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-2">
<h2 id="add-feed-title" className="text-2xl font-serif italic text-nature">Nouveau flux</h2>
<button onClick={onClose} className="icon-btn" aria-label="Fermer">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-paper-muted text-sm mb-6 leading-relaxed font-reading">
Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et les articles automatiquement.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
addFeedMutation.mutate(url);
}}
className="space-y-5"
>
<motion.div
className="w-full max-w-md surface-card p-8"
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
<div className="space-y-2">
<label htmlFor="feed-url" className="block eyebrow text-paper-muted">URL du flux</label>
<input
id="feed-url"
type="url"
inputMode="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input-field"
placeholder="https://exemple.com/feed"
required
autoFocus
aria-invalid={addFeedMutation.isError}
aria-describedby={addFeedMutation.isError ? 'feed-url-error' : undefined}
/>
</div>
{addFeedMutation.isError && (
<p id="feed-url-error" className="text-danger text-sm" role="alert">{addError}</p>
)}
<div className="flex justify-end gap-3">
<button type="button" onClick={onClose} className="btn-ghost">Annuler</button>
<button type="submit" disabled={url.trim().length < 5 || addFeedMutation.isPending} className="btn-primary">
{addFeedMutation.isPending ? 'Ajout…' : "S'abonner"}
</button>
</div>
</form>
{/* OPML import */}
<div className="mt-6 pt-5 border-t border-paper-muted/12">
<input
ref={fileInputRef}
type="file"
accept=".opml,.xml,text/xml,application/xml"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
className="btn-ghost w-full justify-center"
>
<div className="flex justify-between items-center mb-2">
<h2 id="add-feed-title" className="text-2xl font-serif italic text-nature">Nouveau flux</h2>
<button onClick={onClose} className="icon-btn" aria-label="Fermer">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-paper-muted text-sm mb-6 leading-relaxed font-reading">
Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et le contenu automatiquement.
</p>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label htmlFor="feed-url" className="block eyebrow text-paper-muted">URL du flux</label>
<input
id="feed-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input-field"
placeholder="https://exemple.com/feed"
required
autoFocus
/>
</div>
{addFeedMutation.isError && (
<p className="text-danger text-xs" role="alert">Échec de l'ajout. Vérifiez l'URL du flux.</p>
)}
<div className="flex justify-end gap-3">
<button type="button" onClick={onClose} className="btn-ghost">Annuler</button>
<button type="submit" disabled={url.length < 5 || addFeedMutation.isPending} className="btn-primary">
{addFeedMutation.isPending ? 'Ajout…' : "S'abonner"}
</button>
</div>
</form>
{/* OPML import */}
<div className="mt-6 pt-5 border-t border-paper-muted/12">
<input ref={fileInputRef} type="file" accept=".opml,.xml,text/xml" className="hidden" onChange={handleFile} />
<button
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
className="btn-ghost w-full justify-center"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{importMutation.isPending ? 'Import en cours…' : 'Importer un fichier OPML'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{importMutation.isPending ? 'Import en cours…' : 'Importer un fichier OPML'}
</button>
<p className="text-sm text-center mt-2" role="status" aria-live="polite">
{importResult && <span className="text-nature">{importResult}</span>}
{importMutation.isError && <span className="text-danger">Fichier OPML invalide ou trop volumineux.</span>}
</p>
</div>
</div>
</div>
);
}