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

+180 -186
View File
@@ -32,9 +32,16 @@ function isTypingTarget(t: EventTarget | null) {
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.
* Rendered in a portal; the app behind it is made inert while it's open.
* Article opened as a large card above the (dimmed) grid.
* 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) {
const qc = useQueryClient();
@@ -45,7 +52,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
const font = usePrefs((s) => s.font);
const setFontSize = usePrefs((s) => s.setFontSize);
const scrollRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -71,16 +78,19 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
const t = window.setTimeout(() => {
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;
}, 600);
}, 500);
return () => window.clearTimeout(t);
}, [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(() => {
const root = document.getElementById('root');
const previous = document.activeElement as HTMLElement | null;
root?.setAttribute('inert', '');
document.body.style.overflow = 'hidden';
overlayRef.current?.scrollTo({ top: 0 });
titleRef.current?.focus({ preventScroll: true });
return () => {
root?.removeAttribute('inert');
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.
useEffect(() => {
const el = scrollRef.current;
const el = overlayRef.current;
if (!el) return;
let frame = 0;
const update = () => {
frame = 0;
const max = el.scrollHeight - el.clientHeight;
const ratio = max > 0 ? Math.min(1, el.scrollTop / max) : 1;
if (progressRef.current) {
progressRef.current.style.transform = `scaleX(${ratio})`;
progressRef.current.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
if (progressRef.current) progressRef.current.style.transform = `scaleX(${ratio})`;
};
const onScroll = () => {
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');
}, [listArticle.url]);
// Keyboard shortcuts (Miniflux / NetNewsWire conventions).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return;
const el = scrollRef.current;
const el = overlayRef.current;
switch (e.key) {
case 'Escape':
onClose();
break;
case 'j':
case 'n':
case 'ArrowRight':
if (onNext) onNext();
break;
case 'k':
case 'p':
case 'ArrowLeft':
if (onPrev && hasPrev) onPrev();
break;
case 's':
case 'f':
toggleFavorite(listArticle);
break;
case 'm':
toggleRead(listArticle);
break;
case 'v':
case 'o':
openOriginal();
break;
case '+':
@@ -162,7 +157,6 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
setFontSize(fontSize - 1);
break;
case ' ': {
// Space pages down; at the end it moves to the next article.
if (!el || e.shiftKey) return;
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
if (atEnd && onNext) {
@@ -180,17 +174,17 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
return () => window.removeEventListener('keydown', onKey);
}, [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({
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) => {
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,
delta: 80,
swipeDuration: 500,
delta: 90,
swipeDuration: 450,
preventScrollOnSwipe: false,
});
@@ -213,170 +207,156 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
'--reader-font': FONTS[font],
letterSpacing: font === 'legible' ? '0.02em' : undefined,
} as React.CSSProperties;
const measure = MEASURES[width];
const date = listArticle.published_at || listArticle.created_at;
const body = (
<div
ref={scrollRef}
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-carbon animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="reader-title"
ref={overlayRef}
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-nature-dark/45 backdrop-blur-sm animate-fade-in"
onMouseDown={(e) => {
// Click beside the card closes it.
if (e.target === e.currentTarget) onClose();
}}
>
{/* Progress */}
<div className="sticky top-0 z-30 h-0.5 w-full bg-transparent">
<div
ref={progressRef}
className="h-full bg-nature origin-left will-change-transform"
style={{ transform: 'scaleX(0)' }}
role="progressbar"
aria-label="Progression de lecture"
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
{/* Side arrows (desktop) */}
{hasPrev && onPrev && (
<button
onClick={onPrev}
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"
aria-label="Article précédent"
title="Article précédent (k)"
>
<Chevron dir="left" />
</button>
)}
{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 */}
<div className="sticky top-0.5 z-20 bg-carbon/90 backdrop-blur-md border-b border-paper-muted/10">
<div className="mx-auto max-w-5xl flex items-center gap-1.5 px-3 sm:px-6 py-2">
<button onClick={onClose} className="icon-btn" aria-label="Fermer (Échap)" 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={1.8} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="hidden sm:flex items-center gap-1.5">
<button onClick={onPrev} disabled={!hasPrev} className="icon-btn" aria-label="Article précédent (k)" title="Précédent (k)">
<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="M5 15l7-7 7 7" />
</svg>
</button>
<button onClick={onNext} disabled={!onNext} className="icon-btn" aria-label="Article suivant (j)" title="Suivant (j)">
<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="M19 9l-7 7-7-7" />
</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">
<article
{...swipe}
role="dialog"
aria-modal="true"
aria-labelledby="reader-title"
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"
style={{ ...style, boxShadow: 'var(--shadow-float)' }}
>
{/* Card header: source on the left, actions and close on the right */}
<div className="sticky top-0 z-20 bg-carbon-light/95 backdrop-blur border-b border-paper-muted/10">
<div className="flex items-center gap-1 pl-5 pr-2 sm:pl-7 sm:pr-3 h-16">
<div className="flex-1 min-w-0 text-sm">
<p className="font-semibold text-nature truncate">{listArticle.feed_title || 'Article'}</p>
<p className="text-paper-muted truncate">
<time dateTime={date}>{formatLong(date)}</time>, {readingTimeLabel(listArticle.reading_time)} de lecture
</p>
</div>
<button
onClick={() => setSettingsOpen((o) => !o)}
className="icon-btn font-serif text-base"
aria-expanded={settingsOpen}
aria-label="Réglages de lecture"
title="Réglages de lecture"
onClick={() => toggleFavorite(listArticle)}
className={`w-11 h-11 inline-flex items-center justify-center rounded-full transition-colors ${
article.is_favorite ? 'text-earth' : 'text-paper-muted hover:text-earth'
}`}
aria-pressed={article.is_favorite}
aria-label="Favori"
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
>
Aa
</button>
{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 className="w-5 h-5" 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>
</a>
)}
</div>
</div>
<article {...swipe} className="mx-auto max-w-5xl pb-28" style={style}>
{listArticle.image_url && (
<div className="px-0 sm:px-6 pt-0 sm:pt-6">
<img
src={listArticle.image_url}
alt=""
fetchPriority="high"
decoding="async"
referrerPolicy="no-referrer"
className="w-full max-h-[46vh] object-cover sm:rounded-3xl bg-carbon-dark"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
</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 className="relative">
<button
onClick={() => setSettingsOpen((o) => !o)}
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"
aria-expanded={settingsOpen}
aria-label="Affichage du texte"
title="Affichage du texte"
>
Aa
</button>
{settingsOpen && <ReaderSettings onClose={() => setSettingsOpen(false)} />}
</div>
<button
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 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">
<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
id="reader-title"
ref={titleRef}
tabIndex={-1}
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}
</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 */}
<div className="mt-7">
{article.ai_summary ? (
<aside className="bg-nature/8 border-l-4 border-nature p-5 rounded-r-2xl" aria-label="Résumé IA">
<h2 className="eyebrow mb-2">Résumé IA</h2>
<p className="text-paper-white/90 leading-relaxed font-reading">{article.ai_summary}</p>
</aside>
) : (
<>
<button onClick={summarize} disabled={summarizing} className="btn-secondary">
{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 ? 'Génération…' : 'Résumer avec l’IA'}
</button>
{summaryError && <p role="alert" className="mt-2 text-sm text-danger">{summaryError}</p>}
</>
)}
</div>
{article.ai_summary ? (
<aside className="mt-6 bg-nature/8 border-l-4 border-nature p-5 rounded-r-xl" aria-label="Résumé">
<p className="text-paper-white/90 leading-relaxed font-reading">{article.ai_summary}</p>
</aside>
) : (
<div className="mt-5">
<button
onClick={summarize}
disabled={summarizing}
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 ? 'Résumé en cours…' : 'Résumer cet article'}
</button>
{summaryError && <p role="alert" className="mt-2 text-sm text-danger">{summaryError}</p>}
</div>
)}
</header>
{detail ? (
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">
Ce flux ne publie qu’un titre. {listArticle.url && 'Ouvrez l’article original pour le lire en entier.'}
</p>
<p className="text-paper-muted italic">Ce flux ne publie que le titre de l’article.</p>
)
) : isError ? (
<div role="alert" className="text-paper-muted">
<p role="alert" className="text-paper-muted">
Impossible de charger l’article.{' '}
<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">
{[92, 100, 85, 97, 60].map((w, i) => (
@@ -386,32 +366,46 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
)}
{listArticle.url && (
<p className="mt-12">
<a href={listArticle.url} target="_blank" rel="noopener noreferrer" className="btn-secondary">
Lire sur le site d’origine
</a>
</p>
)}
{/* 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"
<a
href={listArticle.url}
target="_blank"
rel="noopener noreferrer"
className="mt-10 inline-flex items-center gap-2 text-sm font-semibold text-nature hover:underline"
>
<span className="flex-1 min-w-0">
<span className="eyebrow block mb-1">À suivre</span>
<span className="block font-serif text-lg text-paper-white leading-snug line-clamp-2 group-hover:text-nature">
{next.title}
</span>
<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>
Lire sur le site d’origine
<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>
</a>
)}
</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>
</div>
);