mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
- 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>
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import {
|
|
usePrefs,
|
|
FONT_SIZE_MAX,
|
|
FONT_SIZE_MIN,
|
|
type ReaderFont,
|
|
type ReaderWidth,
|
|
type ThemePref,
|
|
} from '../../stores/prefsStore';
|
|
|
|
interface ReaderSettingsProps {
|
|
onClose: () => void;
|
|
}
|
|
|
|
const THEMES: { value: ThemePref; label: string; swatch: string }[] = [
|
|
{ value: 'light', label: 'Clair', swatch: '#FFFFFF' },
|
|
{ value: 'sepia', label: 'Sépia', swatch: '#F4ECD8' },
|
|
{ value: 'dark', label: 'Sombre', swatch: '#161D17' },
|
|
{ value: 'system', label: 'Auto', swatch: 'linear-gradient(135deg,#fff 50%,#161D17 50%)' },
|
|
];
|
|
|
|
const FONTS: { value: ReaderFont; label: string; family: string }[] = [
|
|
{ value: 'serif', label: 'Serif', family: 'var(--font-reading)' },
|
|
{ value: 'sans', label: 'Sans', family: 'var(--font-sans)' },
|
|
{ value: 'legible', label: 'Lisible', family: 'var(--font-legible)' },
|
|
];
|
|
|
|
const WIDTHS: { value: ReaderWidth; label: string }[] = [
|
|
{ value: 'narrow', label: 'Étroite' },
|
|
{ value: 'medium', label: 'Moyenne' },
|
|
{ value: 'wide', label: 'Large' },
|
|
];
|
|
|
|
const LEADINGS = [
|
|
{ value: 1.5, label: 'Serré' },
|
|
{ value: 1.65, label: 'Normal' },
|
|
{ value: 1.85, label: 'Aéré' },
|
|
];
|
|
|
|
/** "Aa" popover: theme, font, size, line height and line length. */
|
|
export function ReaderSettings({ onClose }: ReaderSettingsProps) {
|
|
const prefs = usePrefs();
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
panelRef.current?.focus();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.stopImmediatePropagation();
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey, true);
|
|
return () => window.removeEventListener('keydown', onKey, true);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 z-[60]" onClick={onClose} aria-hidden="true" />
|
|
<div
|
|
ref={panelRef}
|
|
tabIndex={-1}
|
|
role="dialog"
|
|
aria-label="Réglages de lecture"
|
|
className="absolute right-0 top-14 z-[61] w-[min(22rem,calc(100vw-2rem))] surface-card p-5 space-y-5 animate-fade-in text-paper-white"
|
|
>
|
|
<fieldset>
|
|
<legend className="eyebrow text-paper-muted mb-2">Thème</legend>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{THEMES.map((t) => (
|
|
<button
|
|
key={t.value}
|
|
onClick={() => prefs.setTheme(t.value)}
|
|
aria-pressed={prefs.theme === t.value}
|
|
className={`flex flex-col items-center gap-1.5 rounded-xl p-2 text-xs font-medium border transition-colors ${
|
|
prefs.theme === t.value ? 'border-nature text-nature' : 'border-paper-muted/20 text-paper-muted hover:text-paper-white'
|
|
}`}
|
|
>
|
|
<span className="w-7 h-7 rounded-full border border-paper-muted/30" style={{ background: t.swatch }} />
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend className="eyebrow text-paper-muted mb-2">Police</legend>
|
|
<div className="segmented w-full">
|
|
{FONTS.map((f) => (
|
|
<button
|
|
key={f.value}
|
|
className="flex-1 !text-sm"
|
|
style={{ fontFamily: f.family }}
|
|
aria-pressed={prefs.font === f.value}
|
|
onClick={() => prefs.setFont(f.value)}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend className="eyebrow text-paper-muted mb-2">Taille du texte</legend>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
className="icon-btn"
|
|
onClick={() => prefs.setFontSize(prefs.fontSize - 1)}
|
|
disabled={prefs.fontSize <= FONT_SIZE_MIN}
|
|
aria-label="Réduire le texte"
|
|
>
|
|
<span className="text-sm font-serif">A</span>
|
|
</button>
|
|
<input
|
|
type="range"
|
|
min={FONT_SIZE_MIN}
|
|
max={FONT_SIZE_MAX}
|
|
value={prefs.fontSize}
|
|
onChange={(e) => prefs.setFontSize(Number(e.target.value))}
|
|
className="flex-1 accent-[var(--color-nature)]"
|
|
aria-label="Taille du texte"
|
|
aria-valuetext={`${prefs.fontSize} pixels`}
|
|
/>
|
|
<button
|
|
className="icon-btn"
|
|
onClick={() => prefs.setFontSize(prefs.fontSize + 1)}
|
|
disabled={prefs.fontSize >= FONT_SIZE_MAX}
|
|
aria-label="Agrandir le texte"
|
|
>
|
|
<span className="text-xl font-serif">A</span>
|
|
</button>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend className="eyebrow text-paper-muted mb-2">Interligne</legend>
|
|
<div className="segmented w-full">
|
|
{LEADINGS.map((l) => (
|
|
<button key={l.value} className="flex-1" aria-pressed={prefs.lineHeight === l.value} onClick={() => prefs.setLineHeight(l.value)}>
|
|
{l.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset className="hidden sm:block">
|
|
<legend className="eyebrow text-paper-muted mb-2">Largeur de colonne</legend>
|
|
<div className="segmented w-full">
|
|
{WIDTHS.map((w) => (
|
|
<button key={w.value} className="flex-1" aria-pressed={prefs.width === w.value} onClick={() => prefs.setWidth(w.value)}>
|
|
{w.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<button className="btn-ghost w-full" onClick={prefs.resetReader}>Réinitialiser</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|