fix(web): open articles as a card, drop search, unstick stale app shell

- Service worker activates new versions immediately (skipWaiting +
  clientsClaim). In prompt mode a waiting worker kept serving the previous
  build to tabs that never send SKIP_WAITING, so the old UI hit the new API
  and showed "Aucun contenu disponible" on every article. Pages already
  controlled get a reload toast; lazy chunks missing after a deploy reload
  once.
- Reader opens as a large card above the dimmed grid: close with ✕, Escape
  or a click beside it; previous/next arrows on the sides (desktop) and a
  bottom bar with the next title (all sizes).
- Remove search, the focus banner and decorative headers; plain sentence-
  case labels instead of tracked capitals; quieter dashboard header.
- Fix horizontal overflow of cards and reader navigation on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antigravity AgentandClaude Opus 5.5 committed 2026-10-09 08:26:25 +02:00
1 parent b24a9806d5
commit ed83525d26
12 files changed
+316 -375

No files matched your search

+5 -4
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect } from 'react'; import { Suspense, useEffect } from 'react';
import { lazyNamed } from './lib/lazy';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAuthStore, clearUserData } from './stores/authStore'; import { useAuthStore, clearUserData } from './stores/authStore';
@@ -8,9 +9,9 @@ import { ProtectedRoute } from './components/ProtectedRoute';
import { UpdateToast } from './components/UpdateToast'; import { UpdateToast } from './components/UpdateToast';
// Route-level code splitting: the reader app and the auth screens load separately. // Route-level code splitting: the reader app and the auth screens load separately.
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage }))); const LoginPage = lazyNamed(() => import('./pages/LoginPage'), 'LoginPage');
const RegisterPage = lazy(() => import('./pages/RegisterPage').then((m) => ({ default: m.RegisterPage }))); const RegisterPage = lazyNamed(() => import('./pages/RegisterPage'), 'RegisterPage');
const RootLayout = lazy(() => import('./layouts/RootLayout').then((m) => ({ default: m.RootLayout }))); const RootLayout = lazyNamed(() => import('./layouts/RootLayout'), 'RootLayout');
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
+1 -1
View File
@@ -91,7 +91,7 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
</span> </span>
{!article.is_read && ( {!article.is_read && (
<div className="absolute top-0 right-0 w-24 h-24 overflow-hidden pointer-events-none" aria-hidden="true"> <div className="absolute top-0 right-0 w-24 h-24 overflow-hidden pointer-events-none" aria-hidden="true">
<span className="absolute top-[18px] -right-[34px] w-[140px] rotate-45 bg-nature text-on-nature text-[11px] font-extrabold uppercase tracking-[0.18em] text-center py-1.5 shadow-lg shadow-black/20"> <span className="absolute top-[18px] -right-[34px] w-[140px] rotate-45 bg-nature text-on-nature text-xs font-bold text-center py-1.5 shadow-lg shadow-black/20">
Nouveau Nouveau
</span> </span>
</div> </div>
-43
View File
@@ -1,43 +0,0 @@
import { forwardRef, useEffect, useState } from 'react';
interface SearchBoxProps {
onSearch: (query: string) => void;
}
/**
* Owns the raw input value so typing only re-renders this box; the list
* receives the debounced query.
*/
export const SearchBox = forwardRef<HTMLInputElement, SearchBoxProps>(function SearchBox({ onSearch }, ref) {
const [value, setValue] = useState('');
useEffect(() => {
const t = window.setTimeout(() => onSearch(value.trim()), 350);
return () => window.clearTimeout(t);
}, [value, onSearch]);
return (
<div className="relative group">
<span className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-paper-muted group-focus-within:text-nature transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
ref={ref}
type="search"
aria-label="Rechercher dans vos articles"
placeholder="Rechercher… /"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setValue('');
e.currentTarget.blur();
}
}}
className="input-field !py-2.5 pl-10 pr-4 !rounded-full w-full sm:w-56 sm:focus:w-72 transition-[width] text-sm"
/>
</div>
);
});
+4 -5
View File
@@ -9,7 +9,6 @@ const GROUPS: { title: string; keys: [string, string][] }[] = [
['m', 'Marquer lu / non lu'], ['m', 'Marquer lu / non lu'],
['s · f', 'Favori'], ['s · f', 'Favori'],
['v', 'Ouvrir l’original'], ['v', 'Ouvrir l’original'],
['/', 'Rechercher'],
['u', 'Basculer Non lus / Tous'], ['u', 'Basculer Non lus / Tous'],
['r', 'Actualiser les flux'], ['r', 'Actualiser les flux'],
], ],
@@ -18,9 +17,9 @@ const GROUPS: { title: string; keys: [string, string][] }[] = [
title: 'Lecture', title: 'Lecture',
keys: [ keys: [
['Espace', 'Défiler, puis article suivant'], ['Espace', 'Défiler, puis article suivant'],
['j · → / k · ←', 'Suivant / précédent'], ['j · → / k · ←', 'Article suivant / précédent'],
['+ / -', 'Taille du texte'], ['+ / -', 'Taille du texte'],
['Échap', 'Fermer'], ['Échap', 'Fermer l’article'],
], ],
}, },
]; ];
@@ -51,11 +50,11 @@ export function ShortcutsHelp({ onClose }: { onClose: () => void }) {
className="surface-card w-full max-w-lg p-7 outline-none animate-rise" className="surface-card w-full max-w-lg p-7 outline-none animate-rise"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 id="shortcuts-title" className="text-2xl font-serif italic text-nature mb-5">Raccourcis clavier</h2> <h2 id="shortcuts-title" className="text-2xl font-serif text-paper-white mb-5">Raccourcis clavier</h2>
<div className="grid sm:grid-cols-2 gap-6"> <div className="grid sm:grid-cols-2 gap-6">
{GROUPS.map((g) => ( {GROUPS.map((g) => (
<section key={g.title}> <section key={g.title}>
<h3 className="eyebrow text-paper-muted mb-3">{g.title}</h3> <h3 className="text-sm font-semibold text-paper-white mb-3">{g.title}</h3>
<dl className="space-y-2 text-sm"> <dl className="space-y-2 text-sm">
{g.keys.map(([k, label]) => ( {g.keys.map(([k, label]) => (
<div key={k} className="flex items-center justify-between gap-3"> <div key={k} className="flex items-center justify-between gap-3">
+4 -4
View File
@@ -85,7 +85,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
<div className="flex items-center justify-between mb-7"> <div className="flex items-center justify-between mb-7">
<button <button
onClick={() => onSelectFeed(null)} onClick={() => onSelectFeed(null)}
className="text-nature text-2xl font-serif italic tracking-tight hover:opacity-80 transition-opacity" className="text-nature text-2xl font-serif tracking-tight hover:opacity-80 transition-opacity"
> >
FlowReader FlowReader
</button> </button>
@@ -141,7 +141,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
: 'bg-nature/5 text-nature border border-nature/20 hover:bg-nature hover:text-on-nature' : 'bg-nature/5 text-nature border border-nature/20 hover:bg-nature hover:text-on-nature'
}`} }`}
> >
<span className="text-[11px] font-bold uppercase tracking-[0.18em] flex items-center"> <span className="text-sm font-medium flex items-center">
<svg className="w-4 h-4 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className="w-4 h-4 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
@@ -150,7 +150,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
</button> </button>
</div> </div>
<h3 className="px-4 pt-4 pb-2 text-[11px] uppercase tracking-[0.3em] font-bold text-paper-muted">Mes flux</h3> <h3 className="px-4 pt-5 pb-2 text-sm font-semibold text-paper-muted">Flux</h3>
{feeds && feeds.length > 0 ? feeds.map((feed) => ( {feeds && feeds.length > 0 ? feeds.map((feed) => (
<div key={feed.id} className="group/item relative flex items-center"> <div key={feed.id} className="group/item relative flex items-center">
@@ -207,7 +207,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-bold text-paper-white truncate">{user?.email ?? 'Compte'}</p> <p className="text-xs font-bold text-paper-white truncate">{user?.email ?? 'Compte'}</p>
<p className="text-[11px] text-paper-muted uppercase tracking-widest font-bold"> <p className="text-xs text-paper-muted">
{user?.is_admin ? 'Administrateur' : 'Lecteur'} {user?.is_admin ? 'Administrateur' : 'Lecteur'}
</p> </p>
</div> </div>
+180 -186
View File
@@ -32,9 +32,16 @@ function isTypingTarget(t: EventTarget | null) {
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
} }
const Chevron = ({ dir }: { dir: 'left' | 'right' }) => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={dir === 'left' ? 'M15 19l-7-7 7-7' : 'M9 5l7 7-7 7'} />
</svg>
);
/** /**
* Full-screen, distraction-free reader used on desktop and mobile. * Article opened as a large card above the (dimmed) grid.
* Rendered in a portal; the app behind it is made inert while it's open. * Close: ✕, Escape or a click beside the card. Previous / next: side arrows
* on desktop, bottom bar on mobile, j/k or ←/→ on the keyboard.
*/ */
export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, onPrev }: ReaderProps) { export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, onPrev }: ReaderProps) {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -45,7 +52,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
const font = usePrefs((s) => s.font); const font = usePrefs((s) => s.font);
const setFontSize = usePrefs((s) => s.setFontSize); const setFontSize = usePrefs((s) => s.setFontSize);
const scrollRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null); const progressRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null); const titleRef = useRef<HTMLHeadingElement>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
@@ -71,16 +78,19 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
const t = window.setTimeout(() => { const t = window.setTimeout(() => {
qc.prefetchQuery({ queryKey: ['article', next.id], queryFn: () => articlesApi.get(next.id), staleTime: 5 * 60 * 1000 }); qc.prefetchQuery({ queryKey: ['article', next.id], queryFn: () => articlesApi.get(next.id), staleTime: 5 * 60 * 1000 });
if (next.image_url) new Image().src = next.image_url; if (next.image_url) new Image().src = next.image_url;
}, 600); }, 500);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [next, qc]); }, [next, qc]);
// Modal behaviour: make the app inert, move focus in, restore it on close. // Modal behaviour: the app behind is inert and doesn't scroll; focus
// moves to the title and returns to the card that opened the article.
useLayoutEffect(() => { useLayoutEffect(() => {
const root = document.getElementById('root'); const root = document.getElementById('root');
const previous = document.activeElement as HTMLElement | null; const previous = document.activeElement as HTMLElement | null;
root?.setAttribute('inert', ''); root?.setAttribute('inert', '');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
overlayRef.current?.scrollTo({ top: 0 });
titleRef.current?.focus({ preventScroll: true });
return () => { return () => {
root?.removeAttribute('inert'); root?.removeAttribute('inert');
document.body.style.overflow = ''; document.body.style.overflow = '';
@@ -88,26 +98,16 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
}; };
}, []); }, []);
// New article: back to top, focus the title.
useLayoutEffect(() => {
scrollRef.current?.scrollTo({ top: 0 });
titleRef.current?.focus({ preventScroll: true });
setSummaryError('');
}, [listArticle.id]);
// Reading progress without React re-renders. // Reading progress without React re-renders.
useEffect(() => { useEffect(() => {
const el = scrollRef.current; const el = overlayRef.current;
if (!el) return; if (!el) return;
let frame = 0; let frame = 0;
const update = () => { const update = () => {
frame = 0; frame = 0;
const max = el.scrollHeight - el.clientHeight; const max = el.scrollHeight - el.clientHeight;
const ratio = max > 0 ? Math.min(1, el.scrollTop / max) : 1; const ratio = max > 0 ? Math.min(1, el.scrollTop / max) : 1;
if (progressRef.current) { if (progressRef.current) progressRef.current.style.transform = `scaleX(${ratio})`;
progressRef.current.style.transform = `scaleX(${ratio})`;
progressRef.current.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
}; };
const onScroll = () => { const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update); if (!frame) frame = requestAnimationFrame(update);
@@ -124,34 +124,29 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
if (listArticle.url) window.open(listArticle.url, '_blank', 'noopener,noreferrer'); if (listArticle.url) window.open(listArticle.url, '_blank', 'noopener,noreferrer');
}, [listArticle.url]); }, [listArticle.url]);
// Keyboard shortcuts (Miniflux / NetNewsWire conventions).
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return; if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return;
const el = scrollRef.current; const el = overlayRef.current;
switch (e.key) { switch (e.key) {
case 'Escape': case 'Escape':
onClose(); onClose();
break; break;
case 'j': case 'j':
case 'n':
case 'ArrowRight': case 'ArrowRight':
if (onNext) onNext(); if (onNext) onNext();
break; break;
case 'k': case 'k':
case 'p':
case 'ArrowLeft': case 'ArrowLeft':
if (onPrev && hasPrev) onPrev(); if (onPrev && hasPrev) onPrev();
break; break;
case 's': case 's':
case 'f':
toggleFavorite(listArticle); toggleFavorite(listArticle);
break; break;
case 'm': case 'm':
toggleRead(listArticle); toggleRead(listArticle);
break; break;
case 'v': case 'v':
case 'o':
openOriginal(); openOriginal();
break; break;
case '+': case '+':
@@ -162,7 +157,6 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
setFontSize(fontSize - 1); setFontSize(fontSize - 1);
break; break;
case ' ': { case ' ': {
// Space pages down; at the end it moves to the next article.
if (!el || e.shiftKey) return; if (!el || e.shiftKey) return;
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 8; const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
if (atEnd && onNext) { if (atEnd && onNext) {
@@ -180,17 +174,17 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [onClose, onNext, onPrev, hasPrev, toggleFavorite, toggleRead, listArticle, openOriginal, fontSize, setFontSize]); }, [onClose, onNext, onPrev, hasPrev, toggleFavorite, toggleRead, listArticle, openOriginal, fontSize, setFontSize]);
// Touch swipe between articles; ignored over horizontally scrollable content. // Optional touch shortcut on phones; the buttons below do the same.
const swipe = useSwipeable({ const swipe = useSwipeable({
onSwipedLeft: (ev) => { onSwipedLeft: (ev) => {
if (!(ev.event.target as Element).closest('pre,table,[data-noswipe]') && onNext) onNext(); if (!(ev.event.target as Element).closest('pre,table') && onNext) onNext();
}, },
onSwipedRight: (ev) => { onSwipedRight: (ev) => {
if (!(ev.event.target as Element).closest('pre,table,[data-noswipe]') && hasPrev && onPrev) onPrev(); if (!(ev.event.target as Element).closest('pre,table') && hasPrev && onPrev) onPrev();
}, },
trackMouse: false, trackMouse: false,
delta: 80, delta: 90,
swipeDuration: 500, swipeDuration: 450,
preventScrollOnSwipe: false, preventScrollOnSwipe: false,
}); });
@@ -213,170 +207,156 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
'--reader-font': FONTS[font], '--reader-font': FONTS[font],
letterSpacing: font === 'legible' ? '0.02em' : undefined, letterSpacing: font === 'legible' ? '0.02em' : undefined,
} as React.CSSProperties; } as React.CSSProperties;
const measure = MEASURES[width];
const date = listArticle.published_at || listArticle.created_at;
const body = ( const body = (
<div <div
ref={scrollRef} ref={overlayRef}
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-carbon animate-fade-in" className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-nature-dark/45 backdrop-blur-sm animate-fade-in"
role="dialog" onMouseDown={(e) => {
aria-modal="true" // Click beside the card closes it.
aria-labelledby="reader-title" if (e.target === e.currentTarget) onClose();
}}
> >
{/* Progress */} {/* Side arrows (desktop) */}
<div className="sticky top-0 z-30 h-0.5 w-full bg-transparent"> {hasPrev && onPrev && (
<div <button
ref={progressRef} onClick={onPrev}
className="h-full bg-nature origin-left will-change-transform" className="hidden lg:flex fixed left-6 top-1/2 -translate-y-1/2 z-10 w-12 h-12 items-center justify-center rounded-full bg-carbon-light text-paper-white shadow-lg hover:text-nature transition-colors"
style={{ transform: 'scaleX(0)' }} aria-label="Article précédent"
role="progressbar" title="Article précédent (k)"
aria-label="Progression de lecture" >
aria-valuemin={0} <Chevron dir="left" />
aria-valuemax={100} </button>
/> )}
</div> {onNext && (
<button
onClick={onNext}
className="hidden lg:flex fixed right-6 top-1/2 -translate-y-1/2 z-10 w-12 h-12 items-center justify-center rounded-full bg-carbon-light text-paper-white shadow-lg hover:text-nature transition-colors"
aria-label="Article suivant"
title="Article suivant (j)"
>
<Chevron dir="right" />
</button>
)}
{/* Toolbar */} <article
<div className="sticky top-0.5 z-20 bg-carbon/90 backdrop-blur-md border-b border-paper-muted/10"> {...swipe}
<div className="mx-auto max-w-5xl flex items-center gap-1.5 px-3 sm:px-6 py-2"> role="dialog"
<button onClick={onClose} className="icon-btn" aria-label="Fermer (Échap)" title="Fermer (Échap)"> aria-modal="true"
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> aria-labelledby="reader-title"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 19l-7-7 7-7" /> className="relative mx-auto w-full max-w-3xl min-h-full md:min-h-0 md:my-10 bg-carbon-light md:rounded-3xl overflow-clip animate-rise"
</svg> style={{ ...style, boxShadow: 'var(--shadow-float)' }}
</button> >
<div className="hidden sm:flex items-center gap-1.5"> {/* Card header: source on the left, actions and close on the right */}
<button onClick={onPrev} disabled={!hasPrev} className="icon-btn" aria-label="Article précédent (k)" title="Précédent (k)"> <div className="sticky top-0 z-20 bg-carbon-light/95 backdrop-blur border-b border-paper-muted/10">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <div className="flex items-center gap-1 pl-5 pr-2 sm:pl-7 sm:pr-3 h-16">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" /> <div className="flex-1 min-w-0 text-sm">
</svg> <p className="font-semibold text-nature truncate">{listArticle.feed_title || 'Article'}</p>
</button> <p className="text-paper-muted truncate">
<button onClick={onNext} disabled={!onNext} className="icon-btn" aria-label="Article suivant (j)" title="Suivant (j)"> <time dateTime={date}>{formatLong(date)}</time>, {readingTimeLabel(listArticle.reading_time)} de lecture
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> </p>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> </div>
</svg>
</button>
</div>
<span className="flex-1" />
<button
onClick={() => toggleRead(listArticle)}
className="icon-btn"
aria-pressed={!article.is_read}
aria-label={article.is_read ? 'Marquer comme non lu (m)' : 'Marquer comme lu (m)'}
title={article.is_read ? 'Marquer non lu (m)' : 'Marquer lu (m)'}
>
<svg className="w-4 h-4" fill={article.is_read ? 'none' : 'currentColor'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<circle cx="12" cy="12" r="5" strokeWidth={1.8} />
</svg>
</button>
<button
onClick={() => toggleFavorite(listArticle)}
className={`icon-btn ${article.is_favorite ? '!bg-earth !text-on-earth !border-earth' : ''}`}
aria-pressed={article.is_favorite}
aria-label="Favori (s)"
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
>
<svg className="w-4 h-4" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<ShareButton article={article} />
<div className="relative">
<button <button
onClick={() => setSettingsOpen((o) => !o)} onClick={() => toggleFavorite(listArticle)}
className="icon-btn font-serif text-base" className={`w-11 h-11 inline-flex items-center justify-center rounded-full transition-colors ${
aria-expanded={settingsOpen} article.is_favorite ? 'text-earth' : 'text-paper-muted hover:text-earth'
aria-label="Réglages de lecture" }`}
title="Réglages de lecture" aria-pressed={article.is_favorite}
aria-label="Favori"
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
> >
Aa <svg className="w-5 h-5" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
</button> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
{settingsOpen && <ReaderSettings onClose={() => setSettingsOpen(false)} />}
</div>
{listArticle.url && (
<a href={listArticle.url} target="_blank" rel="noopener noreferrer" className="icon-btn" aria-label="Ouvrir l'article original (v)" title="Original (v)">
<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.8} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg> </svg>
</a> </button>
)} <ShareButton article={article} className="w-11 h-11 inline-flex items-center justify-center rounded-full text-paper-muted hover:text-nature transition-colors" />
</div> <div className="relative">
</div> <button
onClick={() => setSettingsOpen((o) => !o)}
<article {...swipe} className="mx-auto max-w-5xl pb-28" style={style}> className="w-11 h-11 inline-flex items-center justify-center rounded-full text-paper-muted hover:text-nature font-serif text-base transition-colors"
{listArticle.image_url && ( aria-expanded={settingsOpen}
<div className="px-0 sm:px-6 pt-0 sm:pt-6"> aria-label="Affichage du texte"
<img title="Affichage du texte"
src={listArticle.image_url} >
alt="" Aa
fetchPriority="high" </button>
decoding="async" {settingsOpen && <ReaderSettings onClose={() => setSettingsOpen(false)} />}
referrerPolicy="no-referrer" </div>
className="w-full max-h-[46vh] object-cover sm:rounded-3xl bg-carbon-dark" <button
onError={(e) => (e.currentTarget.style.display = 'none')} onClick={onClose}
/> className="ml-1 w-11 h-11 inline-flex items-center justify-center rounded-full bg-carbon-dark text-paper-white hover:bg-nature hover:text-on-nature transition-colors"
aria-label="Fermer"
title="Fermer (Échap)"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div> </div>
<div className="h-0.5 bg-transparent">
<div ref={progressRef} className="h-full bg-nature origin-left" style={{ transform: 'scaleX(0)' }} aria-hidden="true" />
</div>
</div>
{listArticle.image_url && (
<img
src={listArticle.image_url}
alt=""
fetchPriority="high"
decoding="async"
referrerPolicy="no-referrer"
className="w-full max-h-[42vh] object-cover bg-carbon-dark"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)} )}
<div className="px-5 sm:px-6 mx-auto" style={{ maxWidth: measure }}> <div className="px-5 sm:px-10 pb-10 mx-auto" style={{ maxWidth: `calc(${MEASURES[width]} + 5rem)` }}>
<header className="pt-8 pb-6"> <header className="pt-8 pb-6">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mb-5 text-sm text-paper-muted">
<span className="chip">{listArticle.feed_title || 'Journal'}</span>
<time dateTime={listArticle.published_at || listArticle.created_at}>
{formatLong(listArticle.published_at || listArticle.created_at)}
</time>
<span aria-hidden="true">·</span>
<span>{readingTimeLabel(listArticle.reading_time)} de lecture</span>
</div>
<h1 <h1
id="reader-title" id="reader-title"
ref={titleRef} ref={titleRef}
tabIndex={-1} tabIndex={-1}
className="font-serif text-paper-white leading-[1.15] tracking-tight text-balance outline-none" className="font-serif text-paper-white leading-[1.15] tracking-tight text-balance outline-none"
style={{ fontSize: 'clamp(1.9rem, 1.3rem + 2.4vw, 3rem)' }} style={{ fontSize: 'clamp(1.75rem, 1.3rem + 1.8vw, 2.6rem)' }}
> >
{listArticle.title} {listArticle.title}
</h1> </h1>
{listArticle.author && <p className="mt-4 text-paper-muted text-sm">Par {listArticle.author}</p>} {listArticle.author && <p className="mt-3 text-paper-muted text-sm">Par {listArticle.author}</p>}
{/* AI digest */} {article.ai_summary ? (
<div className="mt-7"> <aside className="mt-6 bg-nature/8 border-l-4 border-nature p-5 rounded-r-xl" aria-label="Résumé">
{article.ai_summary ? ( <p className="text-paper-white/90 leading-relaxed font-reading">{article.ai_summary}</p>
<aside className="bg-nature/8 border-l-4 border-nature p-5 rounded-r-2xl" aria-label="Résumé IA"> </aside>
<h2 className="eyebrow mb-2">Résumé IA</h2> ) : (
<p className="text-paper-white/90 leading-relaxed font-reading">{article.ai_summary}</p> <div className="mt-5">
</aside> <button
) : ( onClick={summarize}
<> disabled={summarizing}
<button onClick={summarize} disabled={summarizing} className="btn-secondary"> className="inline-flex items-center gap-2 text-sm font-medium text-nature hover:underline disabled:opacity-60"
{summarizing ? ( >
<span className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" aria-hidden="true" /> {summarizing && (
) : ( <span className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" aria-hidden="true" />
<span aria-hidden="true">✨</span> )}
)} {summarizing ? 'Résumé en cours…' : 'Résumer cet article'}
{summarizing ? 'Génération…' : 'Résumer avec l’IA'} </button>
</button> {summaryError && <p role="alert" className="mt-2 text-sm text-danger">{summaryError}</p>}
{summaryError && <p role="alert" className="mt-2 text-sm text-danger">{summaryError}</p>} </div>
</> )}
)}
</div>
</header> </header>
{detail ? ( {detail ? (
html ? ( html ? (
<div className={`magazine-content ${font === 'serif' ? 'drop-cap' : ''}`} dangerouslySetInnerHTML={{ __html: html }} /> <div className="magazine-content" dangerouslySetInnerHTML={{ __html: html }} />
) : ( ) : (
<p className="text-paper-muted italic"> <p className="text-paper-muted italic">Ce flux ne publie que le titre de l’article.</p>
Ce flux ne publie qu’un titre. {listArticle.url && 'Ouvrez l’article original pour le lire en entier.'}
</p>
) )
) : isError ? ( ) : isError ? (
<div role="alert" className="text-paper-muted"> <p role="alert" className="text-paper-muted">
Impossible de charger l’article.{' '} Impossible de charger l’article.{' '}
<button className="text-nature underline" onClick={() => refetch()}>Réessayer</button> <button className="text-nature underline" onClick={() => refetch()}>Réessayer</button>
</div> </p>
) : ( ) : (
<div className="space-y-4" aria-busy="true" aria-label="Chargement de l'article"> <div className="space-y-4" aria-busy="true" aria-label="Chargement de l'article">
{[92, 100, 85, 97, 60].map((w, i) => ( {[92, 100, 85, 97, 60].map((w, i) => (
@@ -386,32 +366,46 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
)} )}
{listArticle.url && ( {listArticle.url && (
<p className="mt-12"> <a
<a href={listArticle.url} target="_blank" rel="noopener noreferrer" className="btn-secondary"> href={listArticle.url}
Lire sur le site d’origine target="_blank"
</a> rel="noopener noreferrer"
</p> className="mt-10 inline-flex items-center gap-2 text-sm font-semibold text-nature hover:underline"
)}
{/* Up next */}
{next && onNext && (
<button
onClick={onNext}
className="mt-14 w-full text-left surface-card p-5 flex items-center gap-4 hover:border-nature/40 transition-colors group"
> >
<span className="flex-1 min-w-0"> Lire sur le site d’origine
<span className="eyebrow block mb-1">À suivre</span> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<span className="block font-serif text-lg text-paper-white leading-snug line-clamp-2 group-hover:text-nature"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
{next.title} </svg>
</span> </a>
<span className="block mt-1 text-xs text-paper-muted">
{next.feed_title} · {readingTimeLabel(next.reading_time)}
</span>
</span>
<span className="kbd hidden sm:inline-flex" aria-hidden="true">Espace</span>
</button>
)} )}
</div> </div>
{/* Bottom navigation: always visible on mobile, "up next" on every size */}
<nav className="sticky bottom-0 z-20 border-t border-paper-muted/10 bg-carbon-light/95 backdrop-blur" aria-label="Navigation entre articles">
<div className="flex items-stretch">
<button
onClick={onPrev}
disabled={!hasPrev || !onPrev}
className="flex items-center gap-2 px-4 sm:px-6 min-h-14 text-sm font-medium text-paper-muted hover:text-nature disabled:opacity-35 disabled:pointer-events-none"
>
<Chevron dir="left" />
<span className="hidden sm:inline">Précédent</span>
</button>
<button
onClick={onNext}
disabled={!onNext}
className="flex-1 min-w-0 flex items-center justify-end gap-3 px-4 sm:px-6 min-h-14 text-right hover:text-nature disabled:opacity-35 disabled:pointer-events-none group"
>
<span className="min-w-0">
<span className="block text-xs text-paper-muted">Article suivant</span>
<span className="block text-sm font-semibold text-paper-white group-hover:text-nature truncate">
{next ? next.title : onNext ? 'Charger la suite' : 'Fin de la liste'}
</span>
</span>
<Chevron dir="right" />
</button>
</div>
</nav>
</article> </article>
</div> </div>
); );
+6 -6
View File
@@ -189,21 +189,21 @@
@layer components { @layer components {
/* Eyebrow / kicker label */ /* Eyebrow / kicker label */
.eyebrow { .eyebrow {
@apply text-[11px] uppercase tracking-[0.3em] font-bold text-nature; @apply text-sm font-semibold text-paper-muted;
} }
/* Buttons */ /* Buttons */
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-bold @apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-semibold
min-h-11 px-6 py-2.5 rounded-full text-xs uppercase tracking-[0.16em] min-h-11 px-5 py-2.5 rounded-full text-sm
border border-transparent transition-all duration-200 border border-transparent transition-all duration-200
hover:bg-nature-light hover:shadow-[0_8px_24px_-8px_rgba(21,100,58,0.5)] hover:bg-nature-light hover:shadow-[0_8px_24px_-8px_rgba(21,100,58,0.5)]
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none; active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
} }
.btn-secondary { .btn-secondary {
@apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-bold @apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-semibold
min-h-11 px-5 py-2.5 rounded-full text-xs uppercase tracking-[0.16em] min-h-11 px-5 py-2.5 rounded-full text-sm
border border-nature/35 transition-all duration-200 border border-nature/35 transition-all duration-200
hover:bg-nature hover:text-on-nature hover:border-nature hover:bg-nature hover:text-on-nature hover:border-nature
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none; active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
@@ -246,7 +246,7 @@
/* Chip / badge */ /* Chip / badge */
.chip { .chip {
@apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full @apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full
text-[11px] font-bold uppercase tracking-[0.14em] text-xs font-semibold
bg-nature/10 text-nature; bg-nature/10 text-nature;
} }
+4 -3
View File
@@ -1,11 +1,12 @@
import { lazy, Suspense, useState } from 'react'; import { Suspense, useState } from 'react';
import { lazyNamed } from '../lib/lazy';
import { Sidebar } from '../components/Sidebar'; import { Sidebar } from '../components/Sidebar';
import { MobileTopBar } from '../components/MobileTopBar'; import { MobileTopBar } from '../components/MobileTopBar';
import { DashboardPage } from '../pages/DashboardPage'; import { DashboardPage } from '../pages/DashboardPage';
import { useWebsocket } from '../hooks/useWebsocket'; import { useWebsocket } from '../hooks/useWebsocket';
// Focus mode carries the gesture/animation code: load it only when used. // Focus mode carries the gesture/animation code: load it only when used.
const FocusPage = lazy(() => import('../pages/FocusPage').then((m) => ({ default: m.FocusPage }))); const FocusPage = lazyNamed(() => import('../pages/FocusPage'), 'FocusPage');
export function RootLayout() { export function RootLayout() {
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null); const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
@@ -35,7 +36,7 @@ export function RootLayout() {
/> />
)} )}
{/* Kept mounted in focus mode so the list keeps its scroll position. */} {/* Kept mounted in focus mode so the list keeps its scroll position. */}
<DashboardPage selectedFeedId={selectedFeedId} onEnterFocus={() => setIsFocusMode(true)} hidden={isFocusMode} /> <DashboardPage selectedFeedId={selectedFeedId} hidden={isFocusMode} />
</div> </div>
{isFocusMode && ( {isFocusMode && (
+23
View File
@@ -0,0 +1,23 @@
import { lazy, type ComponentType } from 'react';
/**
* React.lazy for a named export. After a deploy, chunks of the previous build
* no longer exist on the server: reload once to pick up the new version
* instead of showing a blank screen.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function lazyNamed<T extends ComponentType<any>>(load: () => Promise<Record<string, unknown>>, name: string) {
return lazy(async () => {
try {
const mod = await load();
sessionStorage.removeItem('chunk-reload');
return { default: mod[name] as T };
} catch (err) {
if (!sessionStorage.getItem('chunk-reload')) {
sessionStorage.setItem('chunk-reload', '1');
window.location.reload();
}
throw err;
}
});
}
+15 -3
View File
@@ -14,13 +14,25 @@ import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { useUpdate } from './stores/updateStore' import { useUpdate } from './stores/updateStore'
// Prompt-based updates: a new version never reloads the page mid-read. const reload = () => window.location.reload()
const updateSW = registerSW({
registerSW({
immediate: true,
onNeedRefresh() { onNeedRefresh() {
useUpdate.getState().setAvailable(() => updateSW(true)) useUpdate.getState().setAvailable(reload)
}, },
}) })
// The service worker activates new versions right away; when it takes over a
// page that was already controlled, offer a reload instead of forcing one
// under the reader.
if ('serviceWorker' in navigator) {
const hadController = !!navigator.serviceWorker.controller
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (hadController) useUpdate.getState().setAvailable(reload)
})
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
+68 -120
View File
@@ -5,7 +5,6 @@ import { articlesApi, cursorAfter, type Article } from '../api/articles';
import { feedsApi } from '../api/feeds'; import { feedsApi } from '../api/feeds';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { Reader } from '../components/reader/Reader'; import { Reader } from '../components/reader/Reader';
import { SearchBox } from '../components/SearchBox';
import { ShortcutsHelp } from '../components/ShortcutsHelp'; import { ShortcutsHelp } from '../components/ShortcutsHelp';
import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions'; import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions';
import { useLive } from '../hooks/useWebsocket'; import { useLive } from '../hooks/useWebsocket';
@@ -16,7 +15,6 @@ const PAGE_SIZE = 30;
interface DashboardPageProps { interface DashboardPageProps {
selectedFeedId: string | null; selectedFeedId: string | null;
onEnterFocus: () => void;
/** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */ /** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */
hidden?: boolean; hidden?: boolean;
} }
@@ -26,14 +24,12 @@ function isTypingTarget(t: EventTarget | null) {
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
} }
export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: DashboardPageProps) { export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
const qc = useQueryClient(); const qc = useQueryClient();
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const [query, setQuery] = useState('');
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const mainRef = useRef<HTMLElement>(null); const mainRef = useRef<HTMLElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null); const loadMoreRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [showScrollTop, setShowScrollTop] = useState(false); const [showScrollTop, setShowScrollTop] = useState(false);
const unreadPref = usePrefs((s) => s.unreadOnly); const unreadPref = usePrefs((s) => s.unreadOnly);
@@ -44,27 +40,21 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
const favorites = selectedFeedId === 'favorites'; const favorites = selectedFeedId === 'favorites';
const feedId = favorites ? null : selectedFeedId; const feedId = favorites ? null : selectedFeedId;
const searching = query.length > 0; const unreadOnly = unreadPref && !favorites;
const unreadOnly = unreadPref && !favorites && !searching;
const listKey = useMemo( const listKey = useMemo(
() => ['articles', { feedId, favorites, unread: unreadOnly, q: query }] as const, () => ['articles', { feedId, favorites, unread: unreadOnly }] as const,
[feedId, favorites, unreadOnly, query], [feedId, favorites, unreadOnly],
); );
const { data, isPending, isError, refetch, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, isPlaceholderData } = const { data, isPending, isError, refetch, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, isPlaceholderData } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: listKey, queryKey: listKey,
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
searching articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }),
? articlesApi.search(query, PAGE_SIZE, Number(pageParam) || 0)
: articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }),
initialPageParam: '' as string, initialPageParam: '' as string,
getNextPageParam: (last, all) => { getNextPageParam: (last) => (last.length < PAGE_SIZE ? undefined : cursorAfter(last[last.length - 1])),
if (last.length < PAGE_SIZE) return undefined; // Keep showing the previous list while another feed or filter loads.
return searching ? String(all.length * PAGE_SIZE) : cursorAfter(last[last.length - 1]);
},
// Keep showing the previous list while a new filter/search loads.
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@@ -214,9 +204,6 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
case 'v': case 'v':
if (current?.url) window.open(current.url, '_blank', 'noopener,noreferrer'); if (current?.url) window.open(current.url, '_blank', 'noopener,noreferrer');
break; break;
case '/':
searchRef.current?.focus();
break;
case 'u': case 'u':
setUnreadOnly(!unreadPref); setUnreadOnly(!unreadPref);
break; break;
@@ -235,108 +222,73 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]); }, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]);
const heading = searching const heading = favorites ? 'Favoris' : (feedTitle ?? 'Tous les articles');
? `« ${query} »`
: favorites
? 'Mes favoris'
: feedTitle ?? 'La Une';
return ( return (
<main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}> <main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}>
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12 py-8 md:py-12"> <div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12 py-8 md:py-12">
{/* Masthead */} <header className="mb-8 flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
<header className="mb-8 border-b border-paper-muted/15 pb-6"> <div className="min-w-0">
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-5"> <h1 className="text-3xl md:text-4xl font-serif text-paper-white tracking-tight break-words">{heading}</h1>
<div className="min-w-0 flex-1 basis-72"> {!favorites && (
<p className="eyebrow mb-2">{searching ? 'Recherche' : 'Édition du jour'}</p> <p className="text-paper-muted text-sm mt-1.5" aria-live="polite">
<h1 className="text-4xl md:text-6xl font-serif italic text-paper-white tracking-tight text-balance break-words"> {unreadTotal === 0
{heading} ? 'Tout est lu'
</h1> : `${unreadTotal} article${unreadTotal > 1 ? 's' : ''} non lu${unreadTotal > 1 ? 's' : ''}`}
<p className="text-paper-muted text-sm mt-3 font-reading" aria-live="polite">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
{!searching && !favorites && (
<span className="text-nature font-medium"> · {unreadTotal} non lu{unreadTotal > 1 ? 's' : ''}</span>
)}
</p> </p>
</div> )}
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3 w-full sm:w-auto"> <div className="flex items-center gap-2">
<div className="w-full sm:w-auto order-last sm:order-none"> {!favorites && (
<SearchBox ref={searchRef} onSearch={setQuery} /> <div className="segmented" role="group" aria-label="Articles affichés">
<button aria-pressed={unreadPref} onClick={() => setUnreadOnly(true)}>Non lus</button>
<button aria-pressed={!unreadPref} onClick={() => setUnreadOnly(false)}>Tous</button>
</div> </div>
{!favorites && !searching && ( )}
<div className="segmented" role="group" aria-label="Filtrer les articles"> <button
<button aria-pressed={unreadPref} onClick={() => setUnreadOnly(true)}>Non lus</button> onClick={() => refreshMutation.mutate()}
<button aria-pressed={!unreadPref} onClick={() => setUnreadOnly(false)}>Tous</button> disabled={refreshMutation.isPending}
</div> className="icon-btn"
)} title="Actualiser les flux (r)"
aria-label="Actualiser les flux"
>
<svg className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
{!favorites && unreadTotal > 0 && (
<button <button
onClick={() => refreshMutation.mutate()} onClick={() => {
disabled={refreshMutation.isPending} if (window.confirm(`Marquer ${unreadTotal} article${unreadTotal > 1 ? 's' : ''} comme lu${unreadTotal > 1 ? 's' : ''} ?`)) {
markAllMutation.mutate();
}
}}
disabled={markAllMutation.isPending}
className="icon-btn" className="icon-btn"
title="Actualiser les flux (r)" title="Tout marquer comme lu"
aria-label="Actualiser les flux" aria-label="Tout marquer comme lu"
> >
<svg className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2 13l4 4L16 7m-4 10l1 1L22 7" />
</svg> </svg>
</button> </button>
{!favorites && unreadTotal > 0 && ( )}
<button
onClick={() => {
if (window.confirm(`Marquer ${unreadTotal} article${unreadTotal > 1 ? 's' : ''} comme lu${unreadTotal > 1 ? 's' : ''} ?`)) {
markAllMutation.mutate();
}
}}
disabled={markAllMutation.isPending}
className="icon-btn"
title="Tout marquer comme lu"
aria-label="Tout marquer comme lu"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2 13l4 4L16 7m-4 10l1 1L22 7" />
</svg>
</button>
)}
<button onClick={() => setShowHelp(true)} className="icon-btn hidden md:inline-flex" title="Raccourcis clavier (?)" aria-label="Raccourcis clavier">
<span className="font-semibold">?</span>
</button>
</div>
</div> </div>
</header> </header>
{/* New articles announcement (never inserted under the reader's eyes) */} {/* New articles are announced, never inserted under the reader's eyes */}
{newCount > 0 && !searching && ( {newCount > 0 && (
<div className="sticky top-3 z-30 flex justify-center -mt-2 mb-6 pointer-events-none"> <div className="sticky top-3 z-30 flex justify-center mb-6 pointer-events-none">
<button onClick={showNew} className="btn-primary pointer-events-auto shadow-lg animate-rise"> <button onClick={showNew} className="btn-primary pointer-events-auto shadow-lg normal-case tracking-normal text-sm">
{newCount} nouvel{newCount > 1 ? 's' : ''} article{newCount > 1 ? 's' : ''} · Afficher Afficher {newCount} nouvel{newCount > 1 ? 's' : ''} article{newCount > 1 ? 's' : ''}
</button> </button>
</div> </div>
)} )}
{/* Focus CTA */}
{!feedId && !favorites && !searching && unreadTotal > 0 && (
<button
onClick={onEnterFocus}
className="group w-full mb-8 relative overflow-hidden rounded-2xl bg-nature text-on-nature flex items-center justify-between gap-4 p-5 sm:p-6 text-left transition-transform hover:scale-[1.003]"
>
<span className="relative z-10">
<span className="text-[11px] uppercase tracking-[0.3em] font-bold opacity-85">Mode Focus</span>
<span className="block text-xl sm:text-2xl font-serif italic mt-1">Votre session de lecture</span>
<span className="block opacity-90 text-sm mt-1">{unreadTotal} articles non lus à trier d'un geste.</span>
</span>
<span className="relative z-10 shrink-0 inline-flex items-center gap-2 rounded-full bg-carbon-light text-nature font-bold text-xs uppercase tracking-[0.16em] px-5 min-h-11">
Lancer
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</span>
</button>
)}
{/* Grid */} {/* Grid */}
{isPending ? ( {isPending ? (
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3" aria-label="Chargement des articles" role="status"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3" aria-label="Chargement des articles" role="status">
{Array.from({ length: 6 }, (_, i) => ( {Array.from({ length: 6 }, (_, i) => (
<div key={i} className="rounded-2xl bg-carbon-light border border-paper-muted/10 overflow-hidden"> <div key={i} className="rounded-2xl bg-carbon-light border border-paper-muted/10 overflow-hidden">
<div className="aspect-[16/9] bg-carbon-dark animate-pulse" /> <div className="aspect-[16/9] bg-carbon-dark animate-pulse" />
@@ -350,7 +302,7 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
</div> </div>
) : isError && articles.length === 0 ? ( ) : isError && articles.length === 0 ? (
<div role="alert" className="flex flex-col items-center justify-center py-24 border border-dashed border-paper-muted/25 rounded-3xl text-center px-6 gap-4"> <div role="alert" className="flex flex-col items-center justify-center py-24 border border-dashed border-paper-muted/25 rounded-3xl text-center px-6 gap-4">
<p className="text-paper-white font-serif italic text-xl"> <p className="text-paper-white font-serif text-xl">
{navigator.onLine ? 'Impossible de charger vos articles.' : 'Vous êtes hors ligne.'} {navigator.onLine ? 'Impossible de charger vos articles.' : 'Vous êtes hors ligne.'}
</p> </p>
<button onClick={() => refetch()} className="btn-secondary">Réessayer</button> <button onClick={() => refetch()} className="btn-secondary">Réessayer</button>
@@ -358,7 +310,7 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
) : articles.length > 0 ? ( ) : articles.length > 0 ? (
<> <>
<div <div
className={`grid gap-6 sm:grid-cols-2 xl:grid-cols-3 transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`} className={`grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}
> >
{articles.map((article, i) => ( {articles.map((article, i) => (
<ArticleCard <ArticleCard
@@ -378,7 +330,7 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
) : !hasNextPage ? ( ) : !hasNextPage ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="h-px w-16 bg-nature/25" /> <span className="h-px w-16 bg-nature/25" />
<p className="eyebrow text-paper-muted">Fin de l'édition</p> <p className="text-sm text-paper-muted">Fin de la liste</p>
<span className="h-px w-16 bg-nature/25" /> <span className="h-px w-16 bg-nature/25" />
</div> </div>
) : null} ) : null}
@@ -386,25 +338,21 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
</> </>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-24 border border-dashed border-paper-muted/25 rounded-3xl text-center px-6 gap-3"> <div className="flex flex-col items-center justify-center py-24 border border-dashed border-paper-muted/25 rounded-3xl text-center px-6 gap-3">
<p className="text-paper-white font-serif italic text-2xl"> <p className="text-paper-white font-serif text-2xl">
{searching {favorites
? `Aucun article pour « ${query} ».` ? 'Aucun favori'
: favorites : unreadOnly && (feeds?.length ?? 0) > 0
? 'Aucun favori pour le moment.' ? 'Tout est lu'
: unreadOnly && (feeds?.length ?? 0) > 0 : 'Aucun article'}
? 'Vous êtes à jour 🌿'
: 'Votre bibliothèque est vide.'}
</p> </p>
<p className="text-paper-muted text-sm"> <p className="text-paper-muted text-sm">
{searching {favorites
? 'Essayez d’autres mots-clés.' ? 'Touchez l’étoile d’un article pour le retrouver ici.'
: favorites : unreadOnly && (feeds?.length ?? 0) > 0
? 'Appuyez sur l’étoile d’un article pour le garder ici.' ? 'Les nouveaux articles apparaîtront ici.'
: unreadOnly && (feeds?.length ?? 0) > 0 : 'Ajoutez un flux avec le bouton +.'}
? 'Tous les articles sont lus.'
: 'Ajoutez un flux avec le bouton +.'}
</p> </p>
{unreadOnly && !searching && (feeds?.length ?? 0) > 0 && ( {unreadOnly && (feeds?.length ?? 0) > 0 && (
<button onClick={() => setUnreadOnly(false)} className="btn-secondary mt-2">Voir tous les articles</button> <button onClick={() => setUnreadOnly(false)} className="btn-secondary mt-2">Voir tous les articles</button>
)} )}
</div> </div>
+6
View File
@@ -35,6 +35,12 @@ export default defineConfig({
globPatterns: ['**/*.{js,css,html,svg}', '**/*-latin-wght-*.woff2', '**/*-latin-[47]00-*.woff2'], globPatterns: ['**/*.{js,css,html,svg}', '**/*-latin-wght-*.woff2', '**/*-latin-[47]00-*.woff2'],
navigateFallbackDenylist: [/^\/api\//, /^\/health/], navigateFallbackDenylist: [/^\/api\//, /^\/health/],
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
// Activate new versions immediately. A waiting worker would keep
// serving a stale app shell to clients that never send SKIP_WAITING
// (e.g. tabs still running a previous build), so the old UI would talk
// to the new API. The app shows a reload toast instead of reloading.
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [ runtimeCaching: [
{ {
// Opened articles stay readable offline. // Opened articles stay readable offline.