mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-20 05:38:06 +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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user