Merge pull request #9 from R0m1k3/claude/peaceful-brahmagupta-nult7j

feat(web): redesign — three-pane desktop, mobile tab bar, search
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-20 13:38:18 +02:00
commit 22bf05a0bd
38 files changed
+2019 -963

No files matched your search

+6 -1
View File
@@ -110,8 +110,13 @@ les comptes, sessions, flux et articles existants sont conservés.
### Lecture ### Lecture
- Bureau (≥ 1280 px) : trois panneaux — flux, liste, lecteur. Tablette : liste et lecteur côte à côte,
les flux dans un tiroir. Téléphone : une colonne avec une barre d'onglets (À lire, Focus, Favoris, Flux)
et des gestes sur les articles (glisser → lu, glisser ← favori).
- Vue **liste** (vignettes carrées) ou **magazine** (grandes cartes), au choix.
- Recherche dans les titres et le texte des articles : `⌘K` / `Ctrl+K` ou `/`.
- Raccourcis clavier : `j`/`k` naviguer, `o` ouvrir, `m` lu/non lu, `s` favori, `v` original, - Raccourcis clavier : `j`/`k` naviguer, `o` ouvrir, `m` lu/non lu, `s` favori, `v` original,
`/` rechercher, `u` non lus/tous, `r` actualiser, `Espace` page suivante puis article suivant, `/` ou `⌘K` rechercher, `u` non lus/tous, `r` actualiser, `Espace` page suivante puis article suivant,
`?` aide. `?` aide.
- Bouton **Aa** dans le lecteur : thème (clair, sépia, sombre, auto), police (serif, sans, - Bouton **Aa** dans le lecteur : thème (clair, sépia, sombre, auto), police (serif, sans,
Atkinson Hyperlegible), taille, interligne et largeur de colonne. Atkinson Hyperlegible), taille, interligne et largeur de colonne.
+1 -1
View File
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="theme-color" content="#F7F4EC" /> <meta name="theme-color" content="#F6F4EE" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" <meta name="description"
content="FlowReader - Votre édition quotidienne pour une lecture plaisir et sans distractions." /> content="FlowReader - Votre édition quotidienne pour une lecture plaisir et sans distractions." />
+4 -14
View File
@@ -9,8 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/lora": "^5.3.0", "@fontsource-variable/newsreader": "^5.3.0",
"@fontsource-variable/playfair-display": "^5.3.0",
"@fontsource/atkinson-hyperlegible": "^5.3.0", "@fontsource/atkinson-hyperlegible": "^5.3.0",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
@@ -2257,19 +2256,10 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource-variable/lora": { "node_modules/@fontsource-variable/newsreader": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/lora/-/lora-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/newsreader/-/newsreader-5.3.0.tgz",
"integrity": "sha512-2ph8KiZ8Bn141hWuC3ErtpJ+Ui8p2MJQn8TJNRSRmLmnCIOCtHfiBM9R7WLRPeUG/q6hXyHOxWQ8DvRSX+ixvQ==", "integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/playfair-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/playfair-display/-/playfair-display-5.3.0.tgz",
"integrity": "sha512-IHdzTvE8eGIXHteZql7KXoqdc3Gfm+Zs1u4XlSC3MeplkwviT3sx/P6gI0tLwg/xIYLF4ZJYveZ5V+bl9cnDwA==",
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
+1 -2
View File
@@ -11,8 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/lora": "^5.3.0", "@fontsource-variable/newsreader": "^5.3.0",
"@fontsource-variable/playfair-display": "^5.3.0",
"@fontsource/atkinson-hyperlegible": "^5.3.0", "@fontsource/atkinson-hyperlegible": "^5.3.0",
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
+2 -2
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#166534"/> <rect width="32" height="32" rx="8" fill="#1B6B43"/>
<text x="16" y="23" font-family="Playfair Display, serif" font-size="18" font-weight="900" fill="#F9F7F1" text-anchor="middle">F</text> <text x="16" y="23" font-family="Newsreader, Georgia, serif" font-size="18" font-weight="700" fill="#F9F7F1" text-anchor="middle">F</text>
<path d="M8 26 L24 26" stroke="#F9F7F1" stroke-width="1" opacity="0.3"/> <path d="M8 26 L24 26" stroke="#F9F7F1" stroke-width="1" opacity="0.3"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 336 B

After

Width:  |  Height:  |  Size: 339 B

+3 -3
View File
@@ -51,15 +51,15 @@ export function AddFeedModal({ onClose }: AddFeedModalProps) {
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 animate-fade-in" className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4 bg-nature-dark/50 animate-fade-in"
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="add-feed-title" 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="w-full sm:max-w-md surface-card !rounded-b-none sm:!rounded-2xl p-6 sm:p-8 pb-[max(1.5rem,env(safe-area-inset-bottom))] animate-rise" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-2"> <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> <h2 id="add-feed-title" className="text-2xl font-semibold text-paper-white">Ajouter un flux</h2>
<button onClick={onClose} className="icon-btn" aria-label="Fermer"> <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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
+41 -51
View File
@@ -2,12 +2,14 @@ import { memo, useState } from 'react';
import { useSwipeable } from 'react-swipeable'; import { useSwipeable } from 'react-swipeable';
import type { Article } from '../api/articles'; import type { Article } from '../api/articles';
import { formatRelative, readingTimeLabel } from '../lib/format'; import { formatRelative, readingTimeLabel } from '../lib/format';
import { FeedAvatar } from './FeedAvatar';
import { Icon } from './Icon';
import { summaryText } from '../lib/summary';
interface ArticleCardProps { interface ArticleCardProps {
article: Article; article: Article;
/** Position in the list, used by keyboard navigation (j/k). */ /** Position in the list, used by keyboard navigation (j/k). */
index: number; index: number;
isCurrent?: boolean;
onOpen: (article: Article) => void; onOpen: (article: Article) => void;
onToggleRead: (article: Article) => void; onToggleRead: (article: Article) => void;
onToggleFavorite: (article: Article) => void; onToggleFavorite: (article: Article) => void;
@@ -17,13 +19,14 @@ interface ArticleCardProps {
function CoverFallback({ title, feed }: { title: string; feed?: string }) { function CoverFallback({ title, feed }: { title: string; feed?: string }) {
const initial = (feed || title).trim().charAt(0).toUpperCase(); const initial = (feed || title).trim().charAt(0).toUpperCase();
return ( return (
<div className="w-full h-full bg-nature/12 flex items-center justify-center" aria-hidden="true"> <div className="w-full h-full bg-nature-soft flex items-center justify-center" aria-hidden="true">
<span className="font-serif italic text-nature/45 text-7xl leading-none select-none">{initial}</span> <span className="font-serif text-nature/50 text-7xl leading-none select-none">{initial}</span>
</div> </div>
); );
} }
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) { /** Magazine view: a card with a large cover. Same gestures as the list rows. */
function ArticleCardImpl({ article, index, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [imgFailed, setImgFailed] = useState(false); const [imgFailed, setImgFailed] = useState(false);
const [imgLoaded, setImgLoaded] = useState(false); const [imgLoaded, setImgLoaded] = useState(false);
@@ -46,6 +49,7 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
const showImage = article.image_url && !imgFailed; const showImage = article.image_url && !imgFailed;
const date = article.published_at || article.created_at; const date = article.published_at || article.created_at;
const unread = !article.is_read;
return ( return (
<div className="relative cv-auto" data-index={index}> <div className="relative cv-auto" data-index={index}>
@@ -54,25 +58,21 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
<span className={`chip transition-opacity ${offset > 20 ? 'opacity-100' : 'opacity-0'}`}> <span className={`chip transition-opacity ${offset > 20 ? 'opacity-100' : 'opacity-0'}`}>
{article.is_read ? 'Non lu' : 'Lu'} {article.is_read ? 'Non lu' : 'Lu'}
</span> </span>
<span className={`chip bg-earth/15 text-earth transition-opacity ${offset < -20 ? 'opacity-100' : 'opacity-0'}`}> <span className={`chip !bg-earth/15 !text-earth transition-opacity ${offset < -20 ? 'opacity-100' : 'opacity-0'}`}>
Favori Favori
</span> </span>
</div> </div>
<article <article
{...swipe} {...swipe}
className={`group relative z-10 flex flex-col h-full overflow-hidden rounded-2xl bg-carbon-light border transition-[border-color,box-shadow,opacity] className="group relative z-10 flex flex-col h-full overflow-hidden rounded-2xl bg-carbon-light border border-line transition-[border-color,box-shadow] hover:border-nature/40 focus-within:border-nature/40"
${article.is_read ? 'border-paper-muted/10' : 'border-nature/25'}
${isCurrent ? 'ring-2 ring-nature ring-offset-2 ring-offset-carbon' : ''}
hover:border-nature/45 focus-within:border-nature/45`}
style={{ style={{
transform: offset ? `translateX(${offset}px)` : undefined, transform: offset ? `translateX(${offset}px)` : undefined,
transition: offset ? 'none' : 'transform 0.35s cubic-bezier(0.22,1,0.36,1)', transition: offset ? 'none' : 'transform 0.35s cubic-bezier(0.2,0.8,0.2,1)',
boxShadow: 'var(--shadow-soft)',
}} }}
> >
{/* Cover */} {/* Cover */}
<div className={`aspect-[16/9] overflow-hidden relative bg-carbon-dark ${article.is_read ? 'opacity-75' : ''}`}> <div className={`aspect-[16/10] overflow-hidden relative bg-carbon-dark ${article.is_read ? 'opacity-70' : ''}`}>
{showImage ? ( {showImage ? (
<img <img
src={article.image_url} src={article.image_url}
@@ -81,7 +81,7 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
decoding="async" decoding="async"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
width={640} width={640}
height={360} height={400}
className={`w-full h-full object-cover transition-[transform,opacity] duration-700 group-hover:scale-[1.03] ${imgLoaded ? 'opacity-100' : 'opacity-0'}`} className={`w-full h-full object-cover transition-[transform,opacity] duration-700 group-hover:scale-[1.03] ${imgLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImgLoaded(true)} onLoad={() => setImgLoaded(true)}
onError={() => setImgFailed(true)} onError={() => setImgFailed(true)}
@@ -89,25 +89,21 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
) : ( ) : (
<CoverFallback title={article.title} feed={article.feed_title} /> <CoverFallback title={article.title} feed={article.feed_title} />
)} )}
{/* Light bottom shade: gives the cover depth and lifts the feed chip. */}
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/30 to-transparent pointer-events-none" aria-hidden="true" />
<span className="absolute bottom-3 left-3 chip bg-carbon-light/95 shadow-sm max-w-[75%] truncate">
{article.feed_title || 'Journal'}
</span>
{!article.is_read && (
<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-xs font-bold text-center py-1.5 shadow-lg shadow-black/20">
Nouveau
</span>
</div>
)}
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 flex flex-col gap-2.5 px-5 pt-4 pb-2"> <div className="flex-1 flex flex-col gap-2 px-[18px] pt-4 pb-1.5">
<p className="flex items-center gap-1.5 text-xs text-paper-muted min-w-0">
<span aria-hidden="true" className={`w-[7px] h-[7px] rounded-full shrink-0 ${unread ? 'bg-nature' : ''}`} />
<FeedAvatar id={article.feed_id} title={article.feed_title} size={16} />
<span className="font-semibold text-paper-white truncate">{article.feed_title || 'Journal'}</span>
<span aria-hidden="true">·</span>
<time dateTime={date} className="shrink-0">{formatRelative(date)}</time>
</p>
<h2 <h2
className={`text-xl font-serif leading-snug text-balance transition-colors group-hover:text-nature className={`text-xl font-serif leading-[1.25] text-balance transition-colors group-hover:text-nature
${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-semibold'}`} ${unread ? 'text-paper-white font-semibold' : 'text-paper-muted font-medium'}`}
> >
{/* Stretched button: the whole card is clickable and keyboard-focusable. */} {/* Stretched button: the whole card is clickable and keyboard-focusable. */}
<button <button
@@ -115,47 +111,41 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
onClick={() => onOpen(article)} onClick={() => onOpen(article)}
className="text-left after:absolute after:inset-0 after:z-0 focus-visible:outline-none" className="text-left after:absolute after:inset-0 after:z-0 focus-visible:outline-none"
> >
{!article.is_read && <span className="sr-only">Non lu : </span>} {unread && <span className="sr-only">Non lu : </span>}
{article.title} {article.title}
</button> </button>
</h2> </h2>
{article.ai_summary ? ( {(article.ai_summary || article.excerpt) && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading"> <p className="text-paper-muted text-[13px] leading-relaxed line-clamp-3">{article.ai_summary ? summaryText(article.ai_summary) : article.excerpt}</p>
<span className="text-nature font-semibold not-italic">✨ </span> )}
{article.ai_summary}
</p>
) : article.excerpt ? (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">{article.excerpt}</p>
) : null}
<div className="mt-auto flex items-center justify-between pt-2 border-t border-paper-muted/10"> <div className="mt-auto flex items-center gap-2 pt-1 text-xs text-paper-muted">
<p className="text-xs text-paper-muted"> {article.ai_summary && (
<time dateTime={date}>{formatRelative(date)}</time> <span className="chip !py-0.5 !px-2 !text-[11px]">
<span aria-hidden="true"> · </span> <Icon name="sparkle" size={12} strokeWidth={2} />
{readingTimeLabel(article.reading_time)} Résumé IA
</p> </span>
)}
<span>{readingTimeLabel(article.reading_time)}</span>
<span className="flex-1" />
<div className="relative z-10 flex items-center -mr-2"> <div className="relative z-10 flex items-center -mr-2">
<button <button
onClick={() => onToggleRead(article)} onClick={() => onToggleRead(article)}
className="w-11 h-11 inline-flex items-center justify-center rounded-full text-nature hover:bg-nature/10 transition-colors" className="tool-btn"
title={article.is_read ? 'Marquer comme non lu (m)' : 'Marquer comme lu (m)'} title={article.is_read ? 'Marquer comme non lu (m)' : 'Marquer comme lu (m)'}
aria-label={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'} aria-label={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
> >
<svg className="w-[18px] h-[18px]" fill={article.is_read ? 'none' : 'currentColor'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Icon name="check" size={18} />
<circle cx="12" cy="12" r="5" strokeWidth={1.8} />
</svg>
</button> </button>
<button <button
onClick={() => onToggleFavorite(article)} onClick={() => onToggleFavorite(article)}
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 hover:bg-earth/10'}`} className={`tool-btn ${article.is_favorite ? '!text-earth' : ''}`}
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'} title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
aria-label="Favori" aria-label="Favori"
aria-pressed={article.is_favorite} aria-pressed={article.is_favorite}
> >
<svg className="w-[18px] h-[18px]" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Icon name="star" size={18} filled={article.is_favorite} />
<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> </button>
</div> </div>
</div> </div>
+169
View File
@@ -0,0 +1,169 @@
import { memo, useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import type { Article } from '../api/articles';
import { formatRelative, readingTimeLabel } from '../lib/format';
import { FeedAvatar } from './FeedAvatar';
import { Icon } from './Icon';
import { summaryText } from '../lib/summary';
interface ArticleRowProps {
article: Article;
/** Position in the list, used by keyboard navigation (j/k). */
index: number;
/** Open in the reader pane right now. */
selected?: boolean;
onOpen: (article: Article) => void;
onToggleRead: (article: Article) => void;
onToggleFavorite: (article: Article) => void;
}
const SWIPE_MAX = 112;
const SWIPE_TRIGGER = 88;
function Thumb({ article }: { article: Article }) {
const [failed, setFailed] = useState(false);
const [loaded, setLoaded] = useState(false);
if (!article.image_url || failed) return null;
return (
<span className={`shrink-0 w-[76px] h-[76px] md:w-16 md:h-16 rounded-xl overflow-hidden bg-carbon-dark ${article.is_read ? 'opacity-70' : ''}`}>
<img
src={article.image_url}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
width={152}
height={152}
className={`w-full h-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
</span>
);
}
/**
* One article in the list: source, title, summary hint and a square thumbnail.
* Touch swipe → marks read / unread, ← toggles favorite; the action shows
* behind the row while dragging.
*/
function ArticleRowImpl({ article, index, selected, onOpen, onToggleRead, onToggleFavorite }: ArticleRowProps) {
const [offset, setOffset] = useState(0);
const swipe = useSwipeable({
onSwiping: (e) => {
if (e.dir === 'Left' || e.dir === 'Right') setOffset(Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, e.deltaX)));
},
onSwiped: (e) => {
if (e.deltaX > SWIPE_TRIGGER) onToggleRead(article);
else if (e.deltaX < -SWIPE_TRIGGER) onToggleFavorite(article);
setOffset(0);
},
trackMouse: false,
delta: 24,
preventScrollOnSwipe: false,
});
const unread = !article.is_read;
const date = article.published_at || article.created_at;
const armed = Math.abs(offset) > SWIPE_TRIGGER;
return (
<div className="relative cv-row border-b border-line overflow-hidden" data-index={index}>
{/* Action revealed behind the row while swiping */}
{offset !== 0 && (
<div
aria-hidden="true"
className={`absolute inset-0 flex items-center px-6 text-xs font-bold ${
offset > 0 ? 'justify-start bg-nature text-on-nature' : 'justify-end bg-earth text-on-earth'
}`}
>
<span className={`flex flex-col items-center gap-1 transition-transform ${armed ? 'scale-110' : 'scale-90 opacity-80'}`}>
<Icon name={offset > 0 ? 'check' : 'star'} size={24} strokeWidth={2.2} filled={offset < 0 && !article.is_favorite} />
{offset > 0 ? (article.is_read ? 'Non lu' : 'Lu') : article.is_favorite ? 'Retirer' : 'Favori'}
</span>
</div>
)}
<article
{...swipe}
className={`group relative flex items-start gap-3 pl-3 pr-4 py-3.5 transition-colors ${
selected ? 'bg-select' : 'bg-carbon-light hover:bg-carbon'
}`}
style={{
transform: offset ? `translateX(${offset}px)` : undefined,
transition: offset ? 'none' : 'transform 0.3s cubic-bezier(0.2,0.8,0.2,1), background-color 0.15s',
}}
>
<span aria-hidden="true" className={`mt-1.5 w-2 h-2 rounded-full shrink-0 ${unread ? 'bg-nature' : ''}`} />
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<p className="flex items-center gap-1.5 text-xs text-paper-muted min-w-0">
<FeedAvatar id={article.feed_id} title={article.feed_title} size={16} />
<span className="font-semibold text-paper-white truncate">{article.feed_title || 'Journal'}</span>
<span aria-hidden="true">·</span>
<time dateTime={date} className="shrink-0">{formatRelative(date)}</time>
</p>
<h2
className={`font-serif text-[17px] max-md:text-[18px] leading-[1.28] line-clamp-3 text-pretty ${
unread ? 'font-semibold text-paper-white' : 'font-medium text-paper-muted'
}`}
>
{/* Stretched button: the whole row opens the article and is keyboard-focusable. */}
<button
type="button"
onClick={() => onOpen(article)}
aria-current={selected || undefined}
className="text-left after:absolute after:inset-0 focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-inset"
>
{unread && <span className="sr-only">Non lu : </span>}
{article.title}
</button>
</h2>
{(article.ai_summary || article.excerpt) && (
<p className="hidden md:block text-[13px] leading-[1.45] text-paper-muted line-clamp-2">{article.ai_summary ? summaryText(article.ai_summary) : article.excerpt}</p>
)}
<p className="flex items-center gap-2 text-xs text-paper-muted">
{article.ai_summary && (
<span className="chip !py-0.5 !px-2 !text-[11px]">
<Icon name="sparkle" size={12} strokeWidth={2} />
Résumé IA
</span>
)}
<span>{readingTimeLabel(article.reading_time)}</span>
{article.is_favorite && <Icon name="star" size={14} filled className="text-earth" label="Favori" />}
</p>
</div>
<Thumb article={article} />
{/* Desktop quick actions, shown on hover / focus */}
<div className="hidden md:flex absolute right-3 bottom-2 z-10 items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity bg-carbon-light/95 rounded-xl shadow-[var(--shadow-soft)]">
<button
onClick={() => onToggleRead(article)}
className="tool-btn !w-9 !h-9"
title={article.is_read ? 'Marquer comme non lu (m)' : 'Marquer comme lu (m)'}
aria-label={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
>
<Icon name="check" size={17} />
</button>
<button
onClick={() => onToggleFavorite(article)}
className={`tool-btn !w-9 !h-9 ${article.is_favorite ? '!text-earth' : ''}`}
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
aria-label="Favori"
aria-pressed={article.is_favorite}
>
<Icon name="star" size={17} filled={article.is_favorite} />
</button>
</div>
</article>
</div>
);
}
/** Memoised: a row re-renders only when its own article or selection changes. */
export const ArticleRow = memo(ArticleRowImpl);
+7 -14
View File
@@ -16,20 +16,13 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
<div className="min-h-screen flex"> <div className="min-h-screen flex">
{/* Brand panel */} {/* Brand panel */}
<aside className="hidden lg:flex w-[45%] relative overflow-hidden bg-nature-dark text-white flex-col justify-between p-14"> <aside className="hidden lg:flex w-[45%] relative overflow-hidden bg-nature-dark text-white flex-col justify-between p-14">
<div
className="absolute inset-0 opacity-30 pointer-events-none"
style={{
backgroundImage:
'radial-gradient(circle at 20% 20%, rgba(52,210,123,0.35), transparent 45%), radial-gradient(circle at 80% 80%, rgba(180,91,62,0.25), transparent 45%)',
}}
/>
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }} transition={{ duration: 0.6 }}
className="relative z-10" className="relative z-10"
> >
<span className="text-nature-light text-5xl font-serif italic">F.</span> <span aria-hidden="true" className="w-14 h-14 rounded-2xl bg-nature text-on-nature flex items-center justify-center font-serif font-bold text-[34px] leading-none">F</span>
</motion.div> </motion.div>
<motion.div <motion.div
@@ -38,7 +31,7 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
transition={{ duration: 0.7, delay: 0.1 }} transition={{ duration: 0.7, delay: 0.1 }}
className="relative z-10 space-y-6" className="relative z-10 space-y-6"
> >
<h2 className="text-5xl font-serif italic leading-[1.1] text-balance text-white"> <h2 className="text-5xl font-semibold leading-[1.08] tracking-[-0.02em] text-balance text-white">
Le calme dans le chaos de l'information. Le calme dans le chaos de l'information.
</h2> </h2>
<p className="text-white/70 font-reading text-lg max-w-sm leading-relaxed"> <p className="text-white/70 font-reading text-lg max-w-sm leading-relaxed">
@@ -52,21 +45,21 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
</aside> </aside>
{/* Form side */} {/* Form side */}
<main className="flex-1 flex items-center justify-center p-6"> <main className="flex-1 flex items-start sm:items-center justify-center px-6 pt-[max(2.5rem,env(safe-area-inset-top))] pb-10 sm:p-6">
<motion.div <motion.div
initial={{ opacity: 0, y: 16 }} initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="w-full max-w-md" className="w-full max-w-md"
> >
<div className="lg:hidden text-center mb-8"> <div className="lg:hidden mb-6">
<span className="text-nature text-4xl font-serif italic">FlowReader</span> <span aria-hidden="true" className="inline-flex w-[52px] h-[52px] rounded-[15px] bg-nature text-on-nature items-center justify-center font-serif font-bold text-[31px] leading-none">F</span>
</div> </div>
<div className="surface-card p-8 sm:p-10"> <div>
<header className="mb-8"> <header className="mb-8">
<p className="eyebrow mb-2">{eyebrow}</p> <p className="eyebrow mb-2">{eyebrow}</p>
<h1 className="text-4xl font-serif italic text-paper-white mb-2">{title}</h1> <h1 className="text-[34px] font-semibold leading-[1.08] tracking-[-0.02em] text-paper-white mb-2">{title}</h1>
<p className="text-paper-muted text-sm">{subtitle}</p> <p className="text-paper-muted text-sm">{subtitle}</p>
</header> </header>
{children} {children}
+36
View File
@@ -0,0 +1,36 @@
// Every swatch keeps white initials above 4.5:1.
const SWATCHES = ['#A4532E', '#5B4BA6', '#2E5E9E', '#2F6F4E', '#2B7A78', '#8A5A0E', '#4B5563', '#9C3D54'];
function swatchFor(key: string) {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
return SWATCHES[Math.abs(h) % SWATCHES.length];
}
interface FeedAvatarProps {
/** Stable key for the color (the feed id). */
id: string;
title?: string;
size?: number;
className?: string;
}
/** Colored initial standing in for a feed's icon (no third-party favicon request). */
export function FeedAvatar({ id, title, size = 20, className = '' }: FeedAvatarProps) {
const initial = (title || '?').trim().charAt(0).toUpperCase();
return (
<span
aria-hidden="true"
className={`inline-flex items-center justify-center shrink-0 text-white font-bold leading-none select-none ${className}`}
style={{
width: size,
height: size,
borderRadius: Math.round(size * 0.3),
fontSize: Math.round(size * 0.55),
background: swatchFor(id),
}}
>
{initial}
</span>
);
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Stroke icons shared by the whole app (24×24 grid, currentColor).
* Decorative by default; pass `label` when the icon is the only content.
*/
const PATHS = {
search: <><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></>,
inbox: <><path d="M3 13h5l1.5 3h5l1.5-3h5" /><path d="M5.5 5h13l2.5 8v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5z" /></>,
star: <path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8-4.3-4.1 5.9-.9z" />,
bolt: <path d="M13 3L5 14h6l-1 7 8-11h-6z" />,
plus: <path d="M12 5v14M5 12h14" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
checkAll: <><path d="M2 13l4 4L16 7" /><path d="M12 16l1 1L22 7" /></>,
refresh: <><path d="M20 11a8 8 0 0 0-14.3-4.9L4 8" /><path d="M4 4v4h4" /><path d="M4 13a8 8 0 0 0 14.3 4.9L20 16" /><path d="M20 20v-4h-4" /></>,
share: <><path d="M12 3v12" /><path d="M8 7l4-4 4 4" /><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" /></>,
external: <><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></>,
chevronLeft: <path d="M15 18l-6-6 6-6" />,
chevronRight: <path d="M9 6l6 6-6 6" />,
chevronUp: <path d="M6 15l6-6 6 6" />,
chevronDown: <path d="M6 9l6 6 6-6" />,
arrowRight: <path d="M5 12h14M13 6l6 6-6 6" />,
arrowUp: <path d="M12 19V5M6 11l6-6 6 6" />,
close: <path d="M6 6l12 12M18 6L6 18" />,
sparkle: <><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z" /></>,
clock: <><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></>,
undo: <><path d="M9 14L4 9l5-5" /><path d="M4 9h10a6 6 0 0 1 0 12h-3" /></>,
book: <><path d="M2 5h6a4 4 0 0 1 4 4v11a3 3 0 0 0-3-3H2z" /><path d="M22 5h-6a4 4 0 0 0-4 4v11a3 3 0 0 1 3-3h7z" /></>,
list: <><path d="M9 6h11M9 12h11M9 18h11" /><path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" /></>,
grid: <><rect x="3.5" y="3.5" width="7" height="7" rx="1.5" /><rect x="13.5" y="3.5" width="7" height="7" rx="1.5" /><rect x="3.5" y="13.5" width="7" height="7" rx="1.5" /><rect x="13.5" y="13.5" width="7" height="7" rx="1.5" /></>,
sliders: <><path d="M4 7h9M17 7h3M4 17h3M11 17h9" /><circle cx="15" cy="7" r="2" /><circle cx="9" cy="17" r="2" /></>,
rss: <><path d="M5 11a8 8 0 0 1 8 8" /><path d="M5 5a14 14 0 0 1 14 14" /><circle cx="6" cy="18" r="1.3" /></>,
moon: <path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z" />,
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
alert: <><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16.5h.01" /></>,
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" /><circle cx="12" cy="12" r="3" /></>,
eyeOff: <><path d="M3 3l18 18" /><path d="M10.6 5.1A10 10 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-3.2 4.1M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6" /><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" /></>,
lock: <><rect x="5" y="11" width="14" height="9" rx="2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></>,
} as const;
export type IconName = keyof typeof PATHS | 'more';
interface IconProps {
name: IconName;
size?: number;
strokeWidth?: number;
/** Filled shape (e.g. an active star). */
filled?: boolean;
className?: string;
/** Accessible name when the icon carries meaning on its own. */
label?: string;
}
export function Icon({ name, size = 20, strokeWidth = 1.8, filled = false, className, label }: IconProps) {
const a11y = label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true as const };
if (name === 'more') {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className} {...a11y}>
<circle cx="5" cy="12" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="19" cy="12" r="1.8" />
</svg>
);
}
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={filled ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...a11y}
>
{PATHS[name]}
</svg>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useUI } from '../stores/uiStore';
import { useTotalUnread } from '../hooks/useFeeds';
import { Icon, type IconName } from './Icon';
/** Bottom tab bar (phones): everything within thumb reach. */
export function MobileTabBar() {
const scope = useUI((s) => s.scope);
const tab = useUI((s) => s.tab);
const setScope = useUI((s) => s.setScope);
const setTab = useUI((s) => s.setTab);
const setFocusOpen = useUI((s) => s.setFocusOpen);
const unread = useTotalUnread();
const tabs: { id: string; label: string; icon: IconName; current: boolean; onClick: () => void; badge?: string }[] = [
{
id: 'list',
label: 'À lire',
icon: 'inbox',
current: tab === 'list' && scope !== 'favorites',
onClick: () => setScope(scope === 'favorites' ? null : scope),
badge: unread > 0 ? (unread > 99 ? '99+' : String(unread)) : undefined,
},
{ id: 'focus', label: 'Focus', icon: 'bolt', current: false, onClick: () => setFocusOpen(true) },
{ id: 'fav', label: 'Favoris', icon: 'star', current: tab === 'list' && scope === 'favorites', onClick: () => setScope('favorites') },
{ id: 'feeds', label: 'Flux', icon: 'rss', current: tab === 'feeds', onClick: () => setTab('feeds') },
];
return (
<nav aria-label="Navigation" className="md:hidden shrink-0 flex px-2 pt-1.5 pb-safe bg-carbon-light border-t border-line">
{tabs.map((t) => (
<button
key={t.id}
onClick={t.onClick}
aria-current={t.current ? 'page' : undefined}
className={`flex-1 min-h-[50px] flex flex-col items-center gap-0.5 text-[11px] font-semibold transition-colors ${
t.current ? 'text-nature' : 'text-paper-muted'
}`}
>
<span className="relative flex">
<Icon name={t.icon} size={26} strokeWidth={t.current ? 1.9 : 1.8} />
{t.badge && (
<span className="absolute -top-1 left-4 min-w-[18px] h-[18px] px-1 rounded-full bg-nature text-on-nature text-[10px] font-bold flex items-center justify-center border-2 border-carbon-light tabular-nums">
<span className="sr-only">, </span>
{t.badge}
<span className="sr-only"> non lus</span>
</span>
)}
</span>
{t.label}
</button>
))}
</nav>
);
}
+258
View File
@@ -0,0 +1,258 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useSearchParams } from 'react-router-dom';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { articlesApi, type Article } from '../api/articles';
import { useUI } from '../stores/uiStore';
import { formatRelative } from '../lib/format';
import { FeedAvatar } from './FeedAvatar';
import { Icon } from './Icon';
import { summaryText } from '../lib/summary';
const RECENT_KEY = 'flowreader-recent-searches';
function loadRecent(): string[] {
try {
const v = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
return Array.isArray(v) ? v.filter((x) => typeof x === 'string').slice(0, 5) : [];
} catch {
return [];
}
}
function saveRecent(q: string) {
try {
const next = [q, ...loadRecent().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 5);
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {
/* storage unavailable (private mode) */
}
}
/** Wraps the first occurrence of `q` in <mark>. */
function Highlight({ text, q }: { text: string; q: string }) {
const i = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
if (i < 0) return <>{text}</>;
return (
<>
{text.slice(0, i)}
<mark className="bg-nature/20 text-paper-white rounded-[3px] px-px">{text.slice(i, i + q.length)}</mark>
{text.slice(i + q.length)}
</>
);
}
/** A short excerpt centred on the match, so the hit is visible. */
function around(text: string, q: string) {
const i = text.toLowerCase().indexOf(q.toLowerCase());
if (i < 0) return text.slice(0, 140);
const start = Math.max(0, i - 50);
const end = Math.min(text.length, i + q.length + 90);
return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
}
/**
* Full-text search over the user's articles. A centred palette on desktop
* (⌘K, /), a full screen on phones.
*/
export function SearchPanel() {
const setSearchOpen = useUI((s) => s.setSearchOpen);
const setTab = useUI((s) => s.setTab);
const [, setParams] = useSearchParams();
const [input, setInput] = useState('');
const [q, setQ] = useState('');
const [recent, setRecent] = useState(loadRecent);
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const close = () => setSearchOpen(false);
// Debounce typing: one request per pause, not per keystroke.
useEffect(() => {
const t = window.setTimeout(() => {
setQ(input.trim());
setActive(0);
}, 250);
return () => window.clearTimeout(t);
}, [input]);
// Keyed under 'articles' so read / favorite updates are patched in.
const { data: results, isFetching, isError } = useQuery({
queryKey: ['articles', 'search', q],
queryFn: () => articlesApi.search(q, 30),
enabled: q.length >= 2,
placeholderData: keepPreviousData,
staleTime: 30 * 1000,
});
const hits = q.length >= 2 ? (results ?? []) : [];
// Layout effect: its cleanup must run before a reader opened from a result
// makes #root inert in turn.
useLayoutEffect(() => {
const previous = document.activeElement as HTMLElement | null;
inputRef.current?.focus();
const root = document.getElementById('root');
root?.setAttribute('inert', '');
return () => {
root?.removeAttribute('inert');
previous?.focus?.();
};
}, []);
const open = (a: Article) => {
if (q) {
saveRecent(q);
setRecent(loadRecent());
}
setTab('list');
setParams(
(p) => {
const n = new URLSearchParams(p);
n.set('a', a.id);
return n;
},
{ state: { reader: true } },
);
close();
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'ArrowDown' && hits.length) {
e.preventDefault();
setActive((i) => Math.min(hits.length - 1, i + 1));
} else if (e.key === 'ArrowUp' && hits.length) {
e.preventDefault();
setActive((i) => Math.max(0, i - 1));
} else if (e.key === 'Enter' && hits[active]) {
e.preventDefault();
open(hits[active]);
}
};
useEffect(() => {
listRef.current?.querySelector(`[data-i="${active}"]`)?.scrollIntoView({ block: 'nearest' });
}, [active]);
// Portalled out of #root, which is made inert while the panel is open.
return createPortal(
<div
className="fixed inset-0 z-[60] md:flex md:items-start md:justify-center md:p-6 md:pt-[12vh] md:bg-nature-dark/45 animate-fade-in"
onMouseDown={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="Recherche"
onKeyDown={onKeyDown}
className="h-full md:h-auto md:max-h-[70vh] w-full md:max-w-2xl flex flex-col bg-carbon-light md:rounded-2xl md:border md:border-line overflow-hidden md:shadow-[var(--shadow-float)] animate-rise"
>
<div className="shrink-0 flex items-center gap-1.5 pl-4 pr-2 pb-2.5 pt-[max(0.625rem,env(safe-area-inset-top))] border-b border-line bg-carbon md:bg-carbon-light">
<div role="search" className="relative flex-1 min-w-0 flex items-center">
<Icon name="search" size={18} strokeWidth={1.9} className="absolute left-3 text-paper-muted" />
<label htmlFor="search-q" className="sr-only">Rechercher dans vos articles</label>
<input
ref={inputRef}
id="search-q"
type="search"
enterKeyHint="search"
autoComplete="off"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Rechercher dans vos articles"
aria-controls="search-results"
aria-activedescendant={hits[active] ? `search-hit-${active}` : undefined}
className="w-full min-h-11 pl-10 pr-3 rounded-xl border-2 border-nature bg-carbon-light text-paper-white placeholder:text-paper-muted outline-none [&::-webkit-search-cancel-button]:cursor-pointer"
/>
</div>
<button onClick={close} className="btn-ghost !px-3 !text-nature !text-[15px] md:hidden">Annuler</button>
<kbd className="kbd hidden md:inline-flex mx-1">Échap</kbd>
</div>
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
{q.length < 2 ? (
recent.length > 0 ? (
<div>
<h2 className="font-sans px-5 pt-4 pb-2 text-[13px] font-semibold text-paper-muted">Recherches récentes</h2>
<ul role="list">
{recent.map((r) => (
<li key={r}>
<button
onClick={() => setInput(r)}
className="w-full flex items-center gap-3.5 min-h-12 px-5 border-b border-line text-left text-paper-white hover:bg-carbon"
>
<Icon name="clock" size={18} className="text-paper-muted" />
{r}
</button>
</li>
))}
</ul>
</div>
) : (
<p className="px-6 py-10 text-center text-sm text-paper-muted">Cherchez un mot dans les titres et le texte de vos articles.</p>
)
) : isError ? (
<p role="alert" className="px-6 py-10 text-center text-sm text-paper-muted">
{navigator.onLine ? 'La recherche a échoué. Réessayez.' : 'La recherche demande une connexion.'}
</p>
) : hits.length === 0 ? (
<div className="px-8 py-12 text-center" role="status">
{isFetching ? (
<div className="mx-auto w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" aria-label="Recherche en cours" />
) : (
<>
<p className="font-serif text-[22px] font-semibold text-paper-white">Aucun article trouvé</p>
<p className="mt-1 text-sm text-paper-muted">Essayez un autre mot.</p>
</>
)}
</div>
) : (
<>
<p className="px-5 pt-3.5 pb-1.5 text-[13px] font-semibold text-paper-muted" aria-live="polite">
{hits.length}
{hits.length >= 30 ? '+' : ''} résultat{hits.length > 1 ? 's' : ''}
</p>
<ul ref={listRef} id="search-results" role="listbox" aria-label="Résultats" className={isFetching ? 'opacity-70' : ''}>
{hits.map((a, i) => {
const date = a.published_at || a.created_at;
return (
<li
key={a.id}
id={`search-hit-${i}`}
data-i={i}
role="option"
aria-selected={i === active}
onClick={() => open(a)}
onMouseMove={() => setActive(i)}
className={`flex flex-col gap-1 px-5 py-3 border-b border-line cursor-pointer ${i === active ? 'bg-select' : ''}`}
>
<span className="flex items-center gap-1.5 text-xs text-paper-muted">
<FeedAvatar id={a.feed_id} title={a.feed_title} size={16} />
<span className="font-semibold text-paper-white">{a.feed_title}</span>
<span aria-hidden="true">·</span>
<time dateTime={date}>{formatRelative(date)}</time>
</span>
<span className="font-serif text-[17px] leading-snug font-semibold text-paper-white">
<Highlight text={a.title} q={q} />
</span>
{(a.excerpt || a.ai_summary) && (
<span className="text-sm leading-relaxed text-paper-muted line-clamp-2">
<Highlight text={around(a.excerpt || summaryText(a.ai_summary || ''), q)} q={q} />
</span>
)}
</li>
);
})}
</ul>
</>
)}
</div>
</div>
</div>,
document.body,
);
}
+10 -8
View File
@@ -1,12 +1,16 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { Article } from '../api/articles'; import type { Article } from '../api/articles';
import { Icon } from './Icon';
interface ShareButtonProps { interface ShareButtonProps {
article: Pick<Article, 'title' | 'url' | 'excerpt'>; article: Pick<Article, 'title' | 'url' | 'excerpt'>;
className?: string; className?: string;
iconSize?: number;
/** Open the fallback menu upwards (button in a bottom bar). */
menuAbove?: boolean;
} }
export function ShareButton({ article, className = 'icon-btn' }: ShareButtonProps) { export function ShareButton({ article, className = 'icon-btn', iconSize = 18, menuAbove = false }: ShareButtonProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -71,9 +75,7 @@ export function ShareButton({ article, className = 'icon-btn' }: ShareButtonProp
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Icon name="share" size={iconSize} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button> </button>
{copied && ( {copied && (
<span role="status" className="absolute -top-9 left-1/2 -translate-x-1/2 chip whitespace-nowrap">Lien copié</span> <span role="status" className="absolute -top-9 left-1/2 -translate-x-1/2 chip whitespace-nowrap">Lien copié</span>
@@ -85,23 +87,23 @@ export function ShareButton({ article, className = 'icon-btn' }: ShareButtonProp
<div <div
ref={menuRef} ref={menuRef}
role="menu" role="menu"
className="absolute right-0 top-12 w-52 surface-card z-50 py-1 text-sm animate-fade-in" className={`absolute w-52 surface-card z-50 py-1 text-sm animate-fade-in ${menuAbove ? 'left-0 bottom-14' : 'right-0 top-12'}`}
> >
{items.map((it) => ( {items.map((it) => (
<button <button
key={it.label} key={it.label}
role="menuitem" role="menuitem"
onClick={() => openLink(it.href)} onClick={() => openLink(it.href)}
className="w-full text-left px-4 py-2.5 min-h-11 text-paper-white hover:bg-nature/10 focus-visible:bg-nature/10" className="w-full text-left px-4 py-2.5 min-h-11 text-paper-white hover:bg-carbon-dark focus-visible:bg-carbon-dark"
> >
{it.label} {it.label}
</button> </button>
))} ))}
<div className="border-t border-paper-muted/15 my-1" /> <div className="border-t border-line my-1" />
<button <button
role="menuitem" role="menuitem"
onClick={copyToClipboard} onClick={copyToClipboard}
className="w-full text-left px-4 py-2.5 min-h-11 text-paper-white hover:bg-nature/10 focus-visible:bg-nature/10" className="w-full text-left px-4 py-2.5 min-h-11 text-paper-white hover:bg-carbon-dark focus-visible:bg-carbon-dark"
> >
Copier le lien Copier le lien
</button> </button>
+2 -1
View File
@@ -11,6 +11,7 @@ const GROUPS: { title: string; keys: [string, string][] }[] = [
['v', 'Ouvrir l’original'], ['v', 'Ouvrir l’original'],
['u', 'Basculer Non lus / Tous'], ['u', 'Basculer Non lus / Tous'],
['r', 'Actualiser les flux'], ['r', 'Actualiser les flux'],
['/ · ⌘K', 'Rechercher'],
], ],
}, },
{ {
@@ -50,7 +51,7 @@ 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 text-paper-white mb-5">Raccourcis clavier</h2> <h2 id="shortcuts-title" className="text-2xl font-semibold 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}>
+164
View File
@@ -0,0 +1,164 @@
import { useRef, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import { usePrefs } from '../stores/prefsStore';
import { useUI } from '../stores/uiStore';
import { useFeeds, useTotalUnread } from '../hooks/useFeeds';
import { useLogout } from '../hooks/useLogout';
import { onMenuKeyDown, useDismiss } from '../hooks/useDismiss';
import { Icon } from './Icon';
import { FeedAvatar } from './FeedAvatar';
function isDarkNow(theme: string) {
return theme === 'dark' || (theme === 'system' && document.documentElement.classList.contains('dark'));
}
/**
* Feed navigation: always visible on desktop, a drawer on tablets.
* Mobile uses the "Flux" tab instead (FeedsPage).
*/
export function Sidebar() {
const { data: feeds } = useFeeds();
const totalUnread = useTotalUnread();
const scope = useUI((s) => s.scope);
const setScope = useUI((s) => s.setScope);
const setFocusOpen = useUI((s) => s.setFocusOpen);
const setSearchOpen = useUI((s) => s.setSearchOpen);
const openDialog = useUI((s) => s.openDialog);
const user = useAuthStore((s) => s.user);
const theme = usePrefs((s) => s.theme);
const setTheme = usePrefs((s) => s.setTheme);
const logout = useLogout();
const [menuOpen, setMenuOpen] = useState(false);
const menuBtn = useRef<HTMLButtonElement>(null);
const menuPanel = useRef<HTMLDivElement>(null);
const closeMenu = () => {
setMenuOpen(false);
menuBtn.current?.focus();
};
useDismiss(menuOpen, closeMenu, [menuBtn, menuPanel]);
const dark = isDarkNow(theme);
const menuItem = 'w-full text-left flex items-center px-3 min-h-11 rounded-lg text-sm text-paper-white hover:bg-carbon-dark focus-visible:bg-carbon-dark outline-none';
return (
<nav aria-label="Navigation principale" className="h-full flex flex-col gap-5 px-3.5 pt-5 pb-3 overflow-y-auto overscroll-contain">
<div className="flex items-center gap-2.5 px-2">
<span aria-hidden="true" className="w-[30px] h-[30px] rounded-[9px] bg-nature text-on-nature flex items-center justify-center font-serif font-bold text-[19px] leading-none">F</span>
<span className="font-serif text-[21px] font-semibold tracking-tight text-paper-white">FlowReader</span>
</div>
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-2.5 min-h-11 px-3 rounded-xl border border-line bg-carbon-light text-paper-muted text-sm text-left hover:text-paper-white transition-colors"
title="Rechercher (⌘K ou /)"
>
<Icon name="search" size={18} />
<span className="flex-1">Rechercher</span>
<kbd className="kbd !h-5 !text-[11px] !bg-carbon !shadow-none text-paper-muted">⌘K</kbd>
</button>
<ul role="list" className="flex flex-col gap-0.5">
<li>
<button className="nav-item" aria-current={scope === null ? 'page' : undefined} onClick={() => setScope(null)}>
<Icon name="inbox" size={18} className="text-nature" />
<span className="flex-1">À lire</span>
{totalUnread > 0 && <span className="text-xs font-semibold text-nature tabular-nums">{totalUnread}</span>}
</button>
</li>
<li>
<button className="nav-item" aria-current={scope === 'favorites' ? 'page' : undefined} onClick={() => setScope('favorites')}>
<Icon name="star" size={18} className="text-paper-muted" />
<span className="flex-1">Favoris</span>
</button>
</li>
<li>
<button className="nav-item" onClick={() => setFocusOpen(true)} title="Trier les non lus un par un">
<Icon name="bolt" size={18} className="text-paper-muted" />
<span className="flex-1">Mode Focus</span>
</button>
</li>
</ul>
<div className="flex flex-col gap-1 min-h-0">
<div className="flex items-center justify-between pl-2.5">
<h2 className="font-sans text-xs font-semibold text-paper-muted">Mes flux</h2>
<button onClick={() => openDialog('add')} className="tool-btn !text-nature" aria-label="Ajouter un flux" title="Ajouter un flux">
<Icon name="plus" size={18} strokeWidth={2} />
</button>
</div>
{feeds && feeds.length === 0 && (
<p className="px-2.5 text-sm text-paper-muted">Aucun flux pour l’instant. Ajoutez-en un avec +.</p>
)}
<ul role="list" className="flex flex-col gap-px">
{(feeds ?? []).map((f) => (
<li key={f.id}>
<button
className="nav-item !min-h-9 !gap-2.5"
aria-current={scope === f.id ? 'page' : undefined}
onClick={() => setScope(f.id)}
title={f.fetch_error ? `Ce flux ne se met plus à jour : ${f.fetch_error}` : undefined}
>
<FeedAvatar id={f.id} title={f.title} />
<span className="flex-1 min-w-0 truncate">{f.title}</span>
{f.fetch_error ? (
<span className="text-xs font-semibold text-danger">erreur</span>
) : (f.unread_count ?? 0) > 0 ? (
<span className="text-xs text-paper-muted tabular-nums">{f.unread_count}</span>
) : null}
</button>
</li>
))}
</ul>
</div>
<div className="mt-auto pt-3 border-t border-line flex items-center gap-1 pl-2">
<span className="flex-1 min-w-0 text-[13px] text-paper-muted truncate">{user?.email}</span>
<button
onClick={() => setTheme(dark ? 'light' : 'dark')}
className="tool-btn"
aria-label={dark ? 'Passer en mode clair' : 'Passer en mode sombre'}
title={dark ? 'Mode clair' : 'Mode sombre'}
>
<Icon name={dark ? 'sun' : 'moon'} size={18} />
</button>
<div className="relative">
<button
ref={menuBtn}
onClick={() => setMenuOpen((o) => !o)}
className="tool-btn"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label="Plus d’options"
title="Plus d’options"
>
<Icon name="more" size={20} />
</button>
{menuOpen && (
<div
ref={menuPanel}
role="menu"
aria-label="Options"
onKeyDown={onMenuKeyDown}
className="absolute right-0 bottom-full mb-2 z-50 w-60 surface-card p-1.5 animate-fade-in"
>
<button role="menuitem" autoFocus className={menuItem} onClick={() => { setMenuOpen(false); openDialog('manage'); }}>
Gérer mes flux
</button>
<a role="menuitem" href="/api/v1/feeds/export/opml" className={menuItem} onClick={() => setMenuOpen(false)}>
Exporter mes flux (OPML)
</a>
<button role="menuitem" className={menuItem} onClick={() => { setMenuOpen(false); openDialog('help'); }}>
Raccourcis clavier
</button>
<div className="my-1 border-t border-line" role="separator" />
<button role="menuitem" className={`${menuItem} !text-danger hover:!bg-danger/10`} onClick={() => logout.mutate()}>
Se déconnecter
</button>
</div>
)}
</div>
</div>
</nav>
);
}
-35
View File
@@ -1,35 +0,0 @@
import { usePrefs } from '../stores/prefsStore';
function isDarkNow() {
return document.documentElement.classList.contains('dark');
}
/** Quick light/dark switch. Sepia and "system" live in the reader settings. */
export function ThemeToggle() {
const theme = usePrefs((s) => s.theme);
const setTheme = usePrefs((s) => s.setTheme);
const isDark = theme === 'dark' || (theme === 'system' && isDarkNow());
return (
<button
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="icon-btn"
role="switch"
aria-checked={isDark}
aria-label="Mode sombre"
title={isDark ? 'Activer le mode clair' : 'Activer le mode sombre'}
>
<span key={isDark ? 'd' : 'l'} className="flex animate-fade-in">
{isDark ? (
<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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</span>
</button>
);
}
-357
View File
@@ -1,357 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { feedsApi } from '../api/feeds';
import { authApi } from '../api/auth';
import { useAuthStore, clearUserData } from '../stores/authStore';
import { usePrefs } from '../stores/prefsStore';
import { useIsMobile } from '../hooks/useIsMobile';
import { AddFeedModal } from './AddFeedModal';
import { ManageFeedsModal } from './ManageFeedsModal';
import { ShortcutsHelp } from './ShortcutsHelp';
interface TopBarProps {
onSelectFeed: (feedId: string | null) => void;
selectedFeedId: string | null;
onEnterFocus: () => void;
}
interface ScopeItem {
id: string | null;
label: string;
unread: number;
error?: boolean;
}
/** Closes a popover on outside pointer down or Escape, returning focus to its trigger. */
function useDismiss(open: boolean, close: () => void, refs: React.RefObject<HTMLElement | null>[]) {
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!refs.some((r) => r.current?.contains(e.target as Node))) close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
};
document.addEventListener('pointerdown', onDown);
window.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
window.removeEventListener('keydown', onKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, close]);
}
/** Arrow-key navigation between the menu's items. */
function onMenuKeyDown(e: React.KeyboardEvent<HTMLElement>) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[role="menuitemradio"],[role="menuitem"]'));
if (items.length === 0) return;
const i = items.indexOf(document.activeElement as HTMLElement);
const next =
e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : e.key === 'ArrowDown' ? (i + 1) % items.length : (i - 1 + items.length) % items.length;
items[next].focus();
e.preventDefault();
}
const Check = () => (
<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.2} d="M5 13l4 4L19 7" />
</svg>
);
/**
* Navigation bar. The page title is the feed selector: one place says what
* you are looking at and lets you change it.
*/
export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarProps) {
const qc = useQueryClient();
const isMobile = useIsMobile();
const [scopeOpen, setScopeOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const scopeBtn = useRef<HTMLButtonElement>(null);
const scopePanel = useRef<HTMLDivElement>(null);
const menuBtn = useRef<HTMLButtonElement>(null);
const menuPanel = useRef<HTMLDivElement>(null);
const user = useAuthStore((s) => s.user);
const logoutStore = useAuthStore((s) => s.logout);
const theme = usePrefs((s) => s.theme);
const setTheme = usePrefs((s) => s.setTheme);
const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
const logout = useMutation({
mutationFn: () => authApi.logout(),
onSettled: async () => {
logoutStore();
qc.clear();
await clearUserData();
window.location.href = '/login';
},
});
const closeScope = () => {
setScopeOpen(false);
scopeBtn.current?.focus();
};
const closeMenu = () => {
setMenuOpen(false);
menuBtn.current?.focus();
};
useDismiss(scopeOpen, closeScope, [scopeBtn, scopePanel]);
useDismiss(menuOpen, closeMenu, [menuBtn, menuPanel]);
// Focus the selected entry when the feed menu opens.
useEffect(() => {
if (!scopeOpen) return;
const el = scopePanel.current?.querySelector<HTMLElement>('[aria-checked="true"]') ?? scopePanel.current?.querySelector<HTMLElement>('[role="menuitemradio"]');
el?.focus();
}, [scopeOpen]);
useEffect(() => {
if (menuOpen) menuPanel.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}, [menuOpen]);
const totalUnread = (feeds ?? []).reduce((n, f) => n + (f.unread_count ?? 0), 0);
const main: ScopeItem[] = [
{ id: null, label: 'Tous les articles', unread: totalUnread },
{ id: 'favorites', label: 'Favoris', unread: 0 },
];
const feedItems: ScopeItem[] = (feeds ?? []).map((f) => ({
id: f.id,
label: f.title,
unread: f.unread_count ?? 0,
error: !!f.fetch_error,
}));
const current = [...main, ...feedItems].find((i) => i.id === selectedFeedId) ?? main[0];
const isDark = theme === 'dark' || (theme === 'system' && document.documentElement.classList.contains('dark'));
const choose = (id: string | null) => {
onSelectFeed(id);
setScopeOpen(false);
scopeBtn.current?.focus();
};
const renderItem = (item: ScopeItem) => {
const active = item.id === selectedFeedId;
return (
<button
key={item.id ?? 'all'}
role="menuitemradio"
aria-checked={active}
onClick={() => choose(item.id)}
className={`w-full flex items-center gap-3 px-4 min-h-11 text-left rounded-lg transition-colors outline-none
focus-visible:bg-nature/10 hover:bg-nature/8 ${active ? 'text-nature font-semibold' : 'text-paper-white'}`}
>
<span className="w-4 shrink-0 text-nature">{active && <Check />}</span>
<span className="flex-1 min-w-0 truncate">{item.label}</span>
{item.error && (
<span className="text-xs text-danger shrink-0" title="Ce flux ne se met plus à jour">
erreur
</span>
)}
{item.unread > 0 && <span className="text-sm tabular-nums text-paper-muted shrink-0">{item.unread}</span>}
</button>
);
};
const scopeList = (
<div
ref={scopePanel}
role="menu"
aria-label="Choisir les articles affichés"
onKeyDown={onMenuKeyDown}
className={
isMobile
? 'fixed inset-x-0 bottom-0 z-50 max-h-[80dvh] overflow-y-auto rounded-t-3xl bg-carbon-light p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] shadow-[0_-12px_40px_-12px_rgba(0,0,0,0.35)] animate-rise'
: 'absolute left-0 top-full mt-2 z-50 w-80 max-h-[70vh] overflow-y-auto surface-card p-2 animate-fade-in'
}
>
{isMobile && <div className="mx-auto mt-1 mb-2 h-1 w-10 rounded-full bg-paper-muted/30" aria-hidden="true" />}
{main.map(renderItem)}
{feedItems.length > 0 && (
<>
<div className="my-1.5 border-t border-paper-muted/12" role="separator" />
<p className="px-4 pt-1.5 pb-1 text-xs text-paper-muted">Flux</p>
{feedItems.map(renderItem)}
</>
)}
<div className="my-1.5 border-t border-paper-muted/12" role="separator" />
<button
role="menuitem"
onClick={() => {
setScopeOpen(false);
setAddOpen(true);
}}
className="w-full flex items-center gap-3 px-4 min-h-11 text-left rounded-lg text-nature font-medium hover:bg-nature/8 focus-visible:bg-nature/10 outline-none"
>
<span className="w-4 text-center" aria-hidden="true">+</span>
Ajouter un flux
</button>
<button
role="menuitem"
onClick={() => {
setScopeOpen(false);
setManageOpen(true);
}}
className="w-full flex items-center gap-3 px-4 min-h-11 text-left rounded-lg text-paper-white hover:bg-nature/8 focus-visible:bg-nature/10 outline-none"
>
<span className="w-4" aria-hidden="true" />
Gérer mes flux
</button>
</div>
);
return (
<>
<header className="sticky top-0 z-30 bg-carbon/95 border-b border-paper-muted/12" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
<div className="max-w-[2400px] mx-auto px-4 sm:px-6 md:px-12 h-16 flex items-center gap-3 sm:gap-6">
<span className="hidden sm:block text-nature text-lg font-serif shrink-0" aria-hidden="true">
FlowReader
</span>
<span className="hidden sm:block h-6 w-px bg-paper-muted/20" aria-hidden="true" />
{/* Title = feed selector */}
<div className="relative min-w-0 flex-1">
<h1 className="min-w-0">
<button
ref={scopeBtn}
onClick={() => setScopeOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={scopeOpen}
className="group inline-flex items-center gap-2 max-w-full min-h-11 -ml-2 px-2 rounded-lg hover:bg-nature/8 transition-colors"
>
<span className="font-serif text-xl sm:text-2xl text-paper-white truncate">{current.label}</span>
<svg
className={`w-4 h-4 shrink-0 text-paper-muted transition-transform ${scopeOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M19 9l-7 7-7-7" />
</svg>
<span className="sr-only">Changer de flux</span>
</button>
</h1>
{scopeOpen && !isMobile && scopeList}
</div>
<button onClick={onEnterFocus} className="btn-ghost !px-3 shrink-0" title="Trier les articles non lus un par un">
<svg className="w-4 h-4 text-nature" 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" />
</svg>
<span className="hidden sm:inline text-paper-white">Mode Focus</span>
<span className="sr-only sm:hidden">Mode Focus</span>
</button>
<div className="relative shrink-0">
<button
ref={menuBtn}
onClick={() => setMenuOpen((o) => !o)}
className="icon-btn !border-transparent"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label="Menu"
title="Menu"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{menuOpen && (
<div
ref={menuPanel}
role="menu"
aria-label="Menu"
onKeyDown={onMenuKeyDown}
className="absolute right-0 top-full mt-2 z-50 w-64 surface-card p-2 animate-fade-in"
>
{user && <p className="px-4 pt-2 pb-2.5 text-xs text-paper-muted truncate">{user.email}</p>}
<button
role="menuitem"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="w-full flex items-center justify-between px-4 min-h-11 rounded-lg text-sm text-paper-white hover:bg-nature/8 focus-visible:bg-nature/10 outline-none"
>
Mode sombre
<span
className={`relative w-9 h-5 rounded-full transition-colors ${isDark ? 'bg-nature' : 'bg-paper-muted/30'}`}
aria-hidden="true"
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-carbon-light shadow transition-all ${isDark ? 'left-[18px]' : 'left-0.5'}`} />
</span>
<span className="sr-only">{isDark ? 'activé' : 'désactivé'}</span>
</button>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false);
setManageOpen(true);
}}
className="w-full text-left px-4 min-h-11 rounded-lg text-sm text-paper-white hover:bg-nature/8 focus-visible:bg-nature/10 outline-none"
>
Gérer mes flux
</button>
<a
role="menuitem"
href="/api/v1/feeds/export/opml"
onClick={() => setMenuOpen(false)}
className="flex items-center px-4 min-h-11 rounded-lg text-sm text-paper-white hover:bg-nature/8 focus-visible:bg-nature/10 outline-none"
>
Exporter mes flux (OPML)
</a>
{!isMobile && (
<button
role="menuitem"
onClick={() => {
setMenuOpen(false);
setHelpOpen(true);
}}
className="w-full text-left px-4 min-h-11 rounded-lg text-sm text-paper-white hover:bg-nature/8 focus-visible:bg-nature/10 outline-none"
>
Raccourcis clavier
</button>
)}
<div className="my-1.5 border-t border-paper-muted/12" role="separator" />
<button
role="menuitem"
onClick={() => logout.mutate()}
className="w-full text-left px-4 min-h-11 rounded-lg text-sm text-danger hover:bg-danger/10 focus-visible:bg-danger/10 outline-none"
>
Se déconnecter
</button>
</div>
)}
</div>
</div>
</header>
{/* Mobile: the feed list opens as a bottom sheet */}
{scopeOpen && isMobile && (
<>
<div className="fixed inset-0 z-40 bg-nature-dark/45 animate-fade-in" aria-hidden="true" />
{scopeList}
</>
)}
{addOpen && <AddFeedModal onClose={() => setAddOpen(false)} />}
{manageOpen && (
<ManageFeedsModal
onClose={() => setManageOpen(false)}
onDeleted={(id) => {
if (selectedFeedId === id) onSelectFeed(null);
}}
/>
)}
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
</>
);
}
+10 -9
View File
@@ -1,5 +1,6 @@
import type { Article } from '../../api/articles'; import type { Article } from '../../api/articles';
import { formatRelative, readingTimeLabel } from '../../lib/format'; import { formatRelative, readingTimeLabel } from '../../lib/format';
import { summaryText } from '../../lib/summary';
interface FocusCardProps { interface FocusCardProps {
article: Article; article: Article;
@@ -10,9 +11,9 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
const date = article.published_at || article.created_at; const date = article.published_at || article.created_at;
return ( return (
<article className="w-full h-full bg-carbon-light rounded-3xl overflow-hidden flex flex-col relative select-none"> <article className="w-full h-full bg-carbon-light border border-line rounded-[26px] overflow-hidden flex flex-col relative select-none">
{/* Cover */} {/* Cover */}
<div className="h-[52%] relative overflow-hidden bg-carbon-dark shrink-0"> <div className="h-[44%] relative overflow-hidden bg-carbon-dark shrink-0">
{article.image_url ? ( {article.image_url ? (
<img <img
src={article.image_url} src={article.image_url}
@@ -24,8 +25,8 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
onError={(e) => (e.currentTarget.style.display = 'none')} onError={(e) => (e.currentTarget.style.display = 'none')}
/> />
) : ( ) : (
<div className="w-full h-full bg-nature/12 p-8 flex items-end"> <div className="w-full h-full bg-nature-soft p-8 flex items-end">
<span className="text-2xl font-serif italic text-nature leading-tight line-clamp-3">{article.feed_title}</span> <span className="text-2xl font-serif font-semibold text-nature leading-tight line-clamp-3">{article.feed_title}</span>
</div> </div>
)} )}
<span className="absolute top-4 right-4 bg-black/55 text-white text-xs font-semibold px-3 py-1.5 rounded-full"> <span className="absolute top-4 right-4 bg-black/55 text-white text-xs font-semibold px-3 py-1.5 rounded-full">
@@ -43,19 +44,19 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
<time dateTime={date} className="shrink-0">{formatRelative(date)}</time> <time dateTime={date} className="shrink-0">{formatRelative(date)}</time>
</div> </div>
<h2 className="text-xl sm:text-2xl font-serif text-paper-white leading-tight mb-3 line-clamp-3 text-balance"> <h2 className="text-2xl font-semibold text-paper-white leading-[1.15] tracking-[-0.01em] mb-3 line-clamp-3 text-balance">
{article.title} {article.title}
</h2> </h2>
{(article.ai_summary || article.excerpt) && ( {(article.ai_summary || article.excerpt) && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading"> <p className="text-paper-muted text-base leading-normal line-clamp-4 font-reading">
{article.ai_summary || article.excerpt} {article.ai_summary ? summaryText(article.ai_summary) : article.excerpt}
</p> </p>
)} )}
{isTop && ( {isTop && (
<p className="mt-auto pt-3 text-center text-xs text-paper-muted hidden sm:block"> <p className="mt-auto pt-3 text-center text-xs text-paper-muted">
← Garder · Lu → · Entrée pour lire ← Plus tard · Lu → · Entrée pour lire
</p> </p>
)} )}
</div> </div>
+42 -27
View File
@@ -4,6 +4,7 @@ import type { Article } from '../../api/articles';
import { useArticleActions } from '../../hooks/useArticleActions'; import { useArticleActions } from '../../hooks/useArticleActions';
import { FocusCard } from './FocusCard'; import { FocusCard } from './FocusCard';
import { Reader } from '../reader/Reader'; import { Reader } from '../reader/Reader';
import { Icon } from '../Icon';
interface FocusCardStackProps { interface FocusCardStackProps {
articles: Article[]; articles: Article[];
@@ -59,7 +60,7 @@ function SwipeableCard({ article, index, isTop, onAction }: SwipeableCardProps)
</motion.div> </motion.div>
<motion.div style={{ opacity: opacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-earth/15"> <motion.div style={{ opacity: opacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-earth/15">
<div className="border-4 border-earth text-earth font-black text-3xl uppercase tracking-widest px-4 py-2 rounded-lg rotate-12 bg-carbon-light/80"> <div className="border-4 border-earth text-earth font-black text-3xl uppercase tracking-widest px-4 py-2 rounded-lg rotate-12 bg-carbon-light/80">
Garder Plus tard
</div> </div>
</motion.div> </motion.div>
</> </>
@@ -141,15 +142,24 @@ export function FocusCardStack({ articles, onEmpty, onExit }: FocusCardStackProp
if (!top) return null; if (!top) return null;
return ( return (
<div className="relative w-full h-full flex flex-col items-center justify-center gap-6"> <div className="relative w-full h-full flex flex-col items-center justify-center gap-5">
<p className="sr-only" aria-live="polite">{announce}</p> <p className="sr-only" aria-live="polite">{announce}</p>
<span className="inline-block bg-nature-dark/80 px-4 py-2 rounded-full text-white text-xs font-semibold"> {/* Progress through the deck */}
{articles.length - currentIndex} restant{articles.length - currentIndex > 1 ? 's' : ''} <div className="w-full max-w-md flex flex-col items-center gap-2">
</span> <p className="text-xs text-paper-muted tabular-nums">
{currentIndex + 1} sur {articles.length}
</p>
<div className="w-full h-1 rounded-full bg-line overflow-hidden" aria-hidden="true">
<div
className="h-full rounded-full bg-nature transition-[width] duration-300"
style={{ width: `${Math.round((currentIndex / articles.length) * 100)}%` }}
/>
</div>
</div>
{/* Stack */} {/* Stack */}
<div className="relative w-full max-w-md flex-1 max-h-[560px] min-h-[340px] mb-8"> <div className="relative w-full max-w-md flex-1 max-h-[580px] min-h-[340px] mb-9">
<AnimatePresence mode="popLayout" initial={false}> <AnimatePresence mode="popLayout" initial={false}>
{visible.map((article, i) => ( {visible.map((article, i) => (
<SwipeableCard key={article.id} article={article} index={i} isTop={i === 0} onAction={act} /> <SwipeableCard key={article.id} article={article} index={i} isTop={i === 0} onAction={act} />
@@ -157,44 +167,48 @@ export function FocusCardStack({ articles, onEmpty, onExit }: FocusCardStackProp
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* Controls (every gesture has a button equivalent) */} {/* Controls (every gesture has a labelled button equivalent) */}
<div className="flex justify-center items-center gap-3 sm:gap-5 pb-4"> <div className="flex justify-center items-start gap-5 pb-[max(1rem,env(safe-area-inset-bottom))]">
<button <button
onClick={() => act('keep')} onClick={() => act('keep')}
className="w-14 h-14 rounded-full bg-carbon-light border border-earth/30 text-earth shadow-lg hover:bg-earth hover:text-on-earth transition-colors flex items-center justify-center" className="flex flex-col items-center gap-1.5 text-xs font-semibold text-earth"
title="Garder pour plus tard (←)" title="Garder pour plus tard (←)"
aria-label="Garder pour plus tard"
> >
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <span className="w-[60px] h-[60px] rounded-full border-2 border-earth flex items-center justify-center hover:bg-earth hover:text-on-earth transition-colors">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" /> <Icon name="clock" size={26} strokeWidth={2} />
</svg> </span>
Plus tard
</button> </button>
<button <button
onClick={undo} onClick={undo}
disabled={currentIndex === 0} disabled={currentIndex === 0}
className="icon-btn bg-carbon-light shadow" className="pt-1.5 flex flex-col items-center gap-1.5 text-xs font-semibold text-paper-muted disabled:opacity-40"
title="Annuler (u)" title="Annuler (u)"
aria-label="Annuler la dernière action"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <span className="w-12 h-12 rounded-full border border-line bg-carbon-light text-paper-white flex items-center justify-center">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /> <Icon name="undo" size={20} strokeWidth={2} />
</svg> </span>
Annuler
</button> </button>
<button
<button onClick={() => setReading(true)} className="btn-secondary bg-carbon-light" title="Lire l’article (Entrée)"> onClick={() => setReading(true)}
className="pt-1.5 flex flex-col items-center gap-1.5 text-xs font-semibold text-paper-muted"
title="Lire l’article (Entrée)"
>
<span className="w-12 h-12 rounded-full border border-line bg-carbon-light text-paper-white flex items-center justify-center">
<Icon name="book" size={20} strokeWidth={1.9} />
</span>
Lire Lire
</button> </button>
<button <button
onClick={() => act('read')} onClick={() => act('read')}
className="w-14 h-14 rounded-full bg-nature text-on-nature shadow-lg hover:bg-nature-light transition-colors flex items-center justify-center" className="flex flex-col items-center gap-1.5 text-xs font-semibold text-nature"
title="Marquer comme lu (→)" title="Marquer comme lu (→)"
aria-label="Marquer comme lu"
> >
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <span className="w-[60px] h-[60px] rounded-full bg-nature text-on-nature flex items-center justify-center hover:bg-nature-light transition-colors">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <Icon name="check" size={28} strokeWidth={2.4} />
</svg> </span>
Lu
</button> </button>
</div> </div>
@@ -204,6 +218,7 @@ export function FocusCardStack({ articles, onEmpty, onExit }: FocusCardStackProp
article={top} article={top}
next={articles[currentIndex + 1]} next={articles[currentIndex + 1]}
hasPrev={false} hasPrev={false}
backLabel="Mode Focus"
onClose={() => setReading(false)} onClose={() => setReading(false)}
onNext={() => { onNext={() => {
act('read'); act('read');
+9 -14
View File
@@ -1,25 +1,20 @@
import { Icon } from '../Icon';
interface FocusEmptyStateProps { interface FocusEmptyStateProps {
onBack: () => void; onBack: () => void;
} }
export function FocusEmptyState({ onBack }: FocusEmptyStateProps) { export function FocusEmptyState({ onBack }: FocusEmptyStateProps) {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center text-center space-y-8 px-6 animate-rise"> <div className="flex-1 flex flex-col items-center justify-center text-center gap-5 px-6 animate-rise">
<div className="relative"> <span aria-hidden="true" className="w-20 h-20 rounded-full bg-nature-soft text-nature flex items-center justify-center">
<div className="w-28 h-28 rounded-full bg-nature/10 flex items-center justify-center animate-bounce-slow"> <Icon name="check" size={36} strokeWidth={2.2} />
<span className="text-5xl" aria-hidden="true">🌿</span> </span>
</div> <div className="space-y-1.5">
<div className="absolute -top-1 -right-1 w-9 h-9 bg-nature text-on-nature rounded-full flex items-center justify-center font-bold border-4 border-carbon" aria-hidden="true"> <h1 className="text-3xl font-semibold text-paper-white">Tout est trié</h1>
✓
</div>
</div>
<div className="space-y-2">
<h1 className="text-4xl font-serif italic text-paper-white">C'est tout pour le moment !</h1>
<p className="text-paper-muted">Vous êtes à jour sur vos lectures.</p> <p className="text-paper-muted">Vous êtes à jour sur vos lectures.</p>
</div> </div>
<button onClick={onBack} className="btn-primary !px-6" autoFocus>Retour à la liste</button>
<button onClick={onBack} className="btn-primary py-3 px-8" autoFocus>Retour au tableau de bord</button>
</div> </div>
); );
} }
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { articlesApi } from '../../api/articles';
import { patchArticle } from '../../hooks/useArticleActions';
import { usePrefs } from '../../stores/prefsStore';
import { Icon } from '../Icon';
import { toPoints } from '../../lib/summary';
interface AiSummaryProps {
articleId: string;
summary?: string;
}
/** "Résumé IA" block at the top of the reader; offers to generate one when missing. */
export function AiSummary({ articleId, summary }: AiSummaryProps) {
const qc = useQueryClient();
const open = usePrefs((s) => s.summaryOpen);
const setOpen = usePrefs((s) => s.setSummaryOpen);
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
const summarize = async () => {
setPending(true);
setError('');
try {
const res = await articlesApi.summarize(articleId);
patchArticle(qc, articleId, { ai_summary: res.summary });
setOpen(true);
} catch {
setError('Le résumé n’a pas pu être généré. Réessayez plus tard.');
} finally {
setPending(false);
}
};
if (!summary) {
return (
<div className="mt-6">
<button
onClick={summarize}
disabled={pending}
className="inline-flex items-center gap-2 min-h-11 px-4 rounded-full border border-line bg-carbon-light text-nature text-sm font-semibold hover:bg-nature-soft transition-colors disabled:opacity-60"
>
{pending ? (
<span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" aria-hidden="true" />
) : (
<Icon name="sparkle" size={16} strokeWidth={2} />
)}
{pending ? 'Résumé en cours…' : 'Résumer cet article'}
</button>
{error && <p role="alert" className="mt-2 text-sm text-danger">{error}</p>}
</div>
);
}
const points = toPoints(summary);
return (
<section aria-labelledby={`summary-${articleId}`} className="mt-6 rounded-2xl bg-nature-soft pl-5 pr-2 pt-1.5 pb-1.5">
<div className="flex items-center gap-2">
<Icon name="sparkle" size={16} strokeWidth={2} className="text-nature shrink-0" />
<h2 id={`summary-${articleId}`} className="flex-1 font-sans text-[13px] font-bold text-nature">Résumé IA</h2>
<button
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls={`summary-body-${articleId}`}
className="min-h-11 px-3 rounded-full text-[13px] font-semibold text-nature hover:bg-carbon-light/60 transition-colors"
>
{open ? 'Masquer' : 'Afficher'}
</button>
</div>
{open && (
<div id={`summary-body-${articleId}`} className="pb-3.5 pr-3 font-reading text-[17px] leading-normal text-paper-white">
{points ? (
<ul className="list-disc pl-5 space-y-2 marker:text-nature">
{points.map((p, i) => (
<li key={i}>{p}</li>
))}
</ul>
) : (
<p className="text-pretty">{summary}</p>
)}
</div>
)}
</section>
);
}
+187 -176
View File
@@ -3,18 +3,30 @@ import { createPortal } from 'react-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSwipeable } from 'react-swipeable'; import { useSwipeable } from 'react-swipeable';
import { articlesApi, type Article } from '../../api/articles'; import { articlesApi, type Article } from '../../api/articles';
import { patchArticle, useArticleActions } from '../../hooks/useArticleActions'; import { useArticleActions } from '../../hooks/useArticleActions';
import { usePrefs } from '../../stores/prefsStore'; import { usePrefs } from '../../stores/prefsStore';
import { formatLong, readingTimeLabel } from '../../lib/format'; import { formatLong, readingTimeLabel } from '../../lib/format';
import { prepareArticleHtml } from '../../lib/html'; import { prepareArticleHtml } from '../../lib/html';
import { ShareButton } from '../ShareButton'; import { ShareButton } from '../ShareButton';
import { FeedAvatar } from '../FeedAvatar';
import { Icon } from '../Icon';
import { ReaderSettings } from './ReaderSettings'; import { ReaderSettings } from './ReaderSettings';
import { AiSummary } from './AiSummary';
interface ReaderProps { interface ReaderProps {
/** The list item being read (title, image… shown instantly while the body loads). */ /** The list item being read (title, image… shown instantly while the body loads). */
article: Article; article: Article;
next?: Article; next?: Article;
hasPrev?: boolean; hasPrev?: boolean;
/**
* 'pane': the right-hand column of the desktop layout.
* 'overlay': full screen on phones, a card above the dimmed page on larger screens.
*/
variant?: 'pane' | 'overlay';
/** Where the mobile back button returns to. */
backLabel?: string;
/** "3 sur 128" shown in the toolbar. */
position?: string;
onClose: () => void; onClose: () => void;
onNext?: () => void; onNext?: () => void;
onPrev?: () => void; onPrev?: () => void;
@@ -32,18 +44,22 @@ 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>
);
/** /**
* Article opened as a large card above the (dimmed) grid. * The article reader. Close: ✕, Escape, back (or a click beside the card in
* Close: ✕, Escape or a click beside the card. Previous / next: side arrows * overlay mode). Previous / next: toolbar arrows on desktop, "Suivant" in the
* on desktop, bottom bar on mobile, j/k or ←/→ on the keyboard. * bottom bar on phones, j/k or ←/→ on the keyboard, a horizontal swipe on touch.
*/ */
export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, onPrev }: ReaderProps) { export function Reader({
article: listArticle,
next,
hasPrev,
variant = 'overlay',
backLabel = 'Retour',
position,
onClose,
onNext,
onPrev,
}: ReaderProps) {
const qc = useQueryClient(); const qc = useQueryClient();
const { toggleRead, toggleFavorite } = useArticleActions(); const { toggleRead, toggleFavorite } = useArticleActions();
const fontSize = usePrefs((s) => s.fontSize); const fontSize = usePrefs((s) => s.fontSize);
@@ -51,13 +67,13 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
const width = usePrefs((s) => s.width); const width = usePrefs((s) => s.width);
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 overlay = variant === 'overlay';
const overlayRef = useRef<HTMLDivElement>(null); const scrollRef = 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);
const [summarizing, setSummarizing] = useState(false); const [imgFailed, setImgFailed] = useState(false);
const [summaryError, setSummaryError] = useState('');
const { data: detail, isError, refetch } = useQuery({ const { data: detail, isError, refetch } = useQuery({
queryKey: ['article', listArticle.id], queryKey: ['article', listArticle.id],
@@ -82,25 +98,25 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [next, qc]); }, [next, qc]);
// Modal behaviour: the app behind is inert and doesn't scroll; focus // Focus moves to the title. In overlay mode the app behind is inert and
// moves to the title and returns to the card that opened the article. // doesn't scroll, and focus returns to the row that opened the article.
useLayoutEffect(() => { useLayoutEffect(() => {
const root = document.getElementById('root');
const previous = document.activeElement as HTMLElement | null; const previous = document.activeElement as HTMLElement | null;
const root = overlay ? document.getElementById('root') : null;
root?.setAttribute('inert', ''); root?.setAttribute('inert', '');
document.body.style.overflow = 'hidden'; if (overlay) document.body.style.overflow = 'hidden';
overlayRef.current?.scrollTo({ top: 0 }); scrollRef.current?.scrollTo({ top: 0 });
titleRef.current?.focus({ preventScroll: true }); titleRef.current?.focus({ preventScroll: true });
return () => { return () => {
root?.removeAttribute('inert'); root?.removeAttribute('inert');
document.body.style.overflow = ''; if (overlay) document.body.style.overflow = '';
previous?.focus?.({ preventScroll: true }); if (overlay) previous?.focus?.({ preventScroll: true });
}; };
}, []); }, [overlay]);
// Reading progress without React re-renders. // Reading progress without React re-renders.
useEffect(() => { useEffect(() => {
const el = overlayRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
let frame = 0; let frame = 0;
const update = () => { const update = () => {
@@ -127,7 +143,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
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 = overlayRef.current; const el = scrollRef.current;
switch (e.key) { switch (e.key) {
case 'Escape': case 'Escape':
onClose(); onClose();
@@ -174,7 +190,7 @@ 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]);
// Optional touch shortcut on phones; the buttons below do the same. // Optional touch shortcut on phones; the buttons do the same.
const swipe = useSwipeable({ const swipe = useSwipeable({
onSwipedLeft: (ev) => { onSwipedLeft: (ev) => {
if (!(ev.event.target as Element).closest('pre,table') && onNext) onNext(); if (!(ev.event.target as Element).closest('pre,table') && onNext) onNext();
@@ -188,19 +204,6 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
preventScrollOnSwipe: false, preventScrollOnSwipe: false,
}); });
const summarize = async () => {
setSummarizing(true);
setSummaryError('');
try {
const res = await articlesApi.summarize(listArticle.id);
patchArticle(qc, listArticle.id, { ai_summary: res.summary });
} catch {
setSummaryError('Le résumé n’a pas pu être généré. Réessayez plus tard.');
} finally {
setSummarizing(false);
}
};
const style = { const style = {
'--reader-size': `${fontSize}px`, '--reader-size': `${fontSize}px`,
'--reader-leading': String(lineHeight), '--reader-leading': String(lineHeight),
@@ -209,162 +212,160 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
} as React.CSSProperties; } as React.CSSProperties;
const date = listArticle.published_at || listArticle.created_at; const date = listArticle.published_at || listArticle.created_at;
const fav = !!article.is_favorite;
const showImage = !!listArticle.image_url && !imgFailed;
const favButton = (cls: string, size: number) => (
<button
onClick={() => toggleFavorite(listArticle)}
className={`${cls} ${fav ? '!text-earth' : ''}`}
aria-pressed={fav}
aria-label="Favori"
title={fav ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
>
<Icon name="star" size={size} filled={fav} />
</button>
);
const body = ( const body = (
<div <div
ref={overlayRef} ref={scrollRef}
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-nature-dark/45 backdrop-blur-sm animate-fade-in" className={
overlay
? 'fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-carbon-light md:bg-nature-dark/45 md:backdrop-blur-sm animate-fade-in'
: 'h-full overflow-y-auto overscroll-contain bg-carbon-light'
}
onMouseDown={(e) => { onMouseDown={(e) => {
// Click beside the card closes it. // Click beside the card closes it.
if (e.target === e.currentTarget) onClose(); if (overlay && e.target === e.currentTarget) onClose();
}} }}
> >
{/* 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>
)}
<article <article
{...swipe} {...swipe}
role="dialog" role={overlay ? 'dialog' : undefined}
aria-modal="true" aria-modal={overlay ? true : undefined}
aria-labelledby="reader-title" 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" className={
style={{ ...style, boxShadow: 'var(--shadow-float)' }} overlay
? 'relative mx-auto w-full max-w-3xl min-h-full md:min-h-0 md:my-10 bg-carbon-light md:rounded-3xl md:shadow-[var(--shadow-float)] overflow-clip animate-rise flex flex-col'
: 'min-h-full flex flex-col'
}
style={style}
> >
{/* Card header: source on the left, actions and close on the right */} {/* Toolbar */}
<div className="sticky top-0 z-20 bg-carbon-light/95 backdrop-blur border-b border-paper-muted/10"> <div className={`sticky top-0 z-20 bg-carbon-light/95 backdrop-blur border-b border-line ${overlay ? 'pt-safe md:pt-0' : ''}`}>
<div className="flex items-center gap-1 pl-5 pr-2 sm:pl-7 sm:pr-3 h-16"> <div className="flex items-center gap-0.5 h-[52px] md:h-[60px] pl-1 pr-1.5 md:px-3">
<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)}<span className="hidden sm:inline"> de lecture</span>
</p>
</div>
<button <button
onClick={() => toggleFavorite(listArticle)} onClick={onClose}
className={`w-11 h-11 inline-flex items-center justify-center rounded-full transition-colors ${ className="md:hidden inline-flex items-center gap-0.5 min-h-11 pl-1 pr-2.5 rounded-full text-nature text-base font-semibold max-w-[45%]"
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)'}
> >
<svg className="w-5 h-5" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Icon name="chevronLeft" size={24} strokeWidth={2} />
<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" /> <span className="truncate">{backLabel}</span>
</svg>
</button> </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="hidden md:flex items-center">
<button onClick={onPrev} disabled={!hasPrev || !onPrev} className="tool-btn !text-paper-white" aria-label="Article précédent" title="Article précédent (k)">
<Icon name="chevronUp" size={20} strokeWidth={2} />
</button>
<button onClick={onNext} disabled={!onNext} className="tool-btn !text-paper-white" aria-label="Article suivant" title="Article suivant (j)">
<Icon name="chevronDown" size={20} strokeWidth={2} />
</button>
</div>
<span className="flex-1 min-w-0 text-center md:text-left md:pl-2 text-xs text-paper-muted tabular-nums truncate">{position}</span>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setSettingsOpen((o) => !o)} 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" className="tool-btn !text-paper-white font-serif !text-lg font-semibold"
aria-expanded={settingsOpen} aria-expanded={settingsOpen}
aria-label="Affichage du texte" aria-label="Réglages de lecture"
title="Affichage du texte" title="Taille, police, thème"
> >
Aa Aa
</button> </button>
{settingsOpen && <ReaderSettings onClose={() => setSettingsOpen(false)} />} {settingsOpen && <ReaderSettings onClose={() => setSettingsOpen(false)} />}
</div> </div>
<button <div className="hidden md:flex items-center gap-0.5">
onClick={onClose} {favButton('tool-btn !text-paper-white', 19)}
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" <ShareButton article={article} className="tool-btn !text-paper-white" />
aria-label="Fermer" {listArticle.url && (
title="Fermer (Échap)" <a
> href={listArticle.url}
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> target="_blank"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> rel="noopener noreferrer"
</svg> title="Ouvrir l’original (v)"
</button> className="ml-1.5 hidden lg:inline-flex items-center gap-2 min-h-10 px-3.5 rounded-full border border-line text-[13px] font-semibold text-paper-white hover:border-nature/50 transition-colors"
>
Ouvrir l’original
<Icon name="external" size={15} strokeWidth={1.9} />
</a>
)}
<button onClick={onClose} className="tool-btn ml-1" aria-label="Fermer l’article" title="Fermer (Échap)">
<Icon name="close" size={20} strokeWidth={2} />
</button>
</div>
</div> </div>
<div className="h-0.5 bg-transparent"> <div className="h-0.5">
<div ref={progressRef} className="h-full bg-nature origin-left" style={{ transform: 'scaleX(0)' }} aria-hidden="true" /> <div ref={progressRef} className="h-full bg-nature origin-left" style={{ transform: 'scaleX(0)' }} aria-hidden="true" />
</div> </div>
</div> </div>
{listArticle.image_url && ( <div className="w-full px-5 md:px-8 pt-6 md:pt-10 pb-10 mx-auto" style={{ maxWidth: `calc(${MEASURES[width]} + 4rem)` }}>
<img <header>
src={listArticle.image_url} <p className="flex items-center gap-2 flex-wrap text-[13px] text-paper-muted">
alt="" <FeedAvatar id={listArticle.feed_id} title={listArticle.feed_title} />
fetchPriority="high" <span className="font-semibold text-nature">{listArticle.feed_title || 'Article'}</span>
decoding="async" <span aria-hidden="true">·</span>
referrerPolicy="no-referrer" <time dateTime={date}>{formatLong(date)}</time>
className="w-full max-h-[42vh] object-cover bg-carbon-dark" <span aria-hidden="true">·</span>
onError={(e) => (e.currentTarget.style.display = 'none')} <span>{readingTimeLabel(listArticle.reading_time)} de lecture</span>
/> </p>
)}
<div className="px-5 sm:px-10 pb-10 mx-auto" style={{ maxWidth: `calc(${MEASURES[width]} + 5rem)` }}>
<header className="pt-8 pb-6">
<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="mt-3.5 font-semibold text-paper-white leading-[1.13] tracking-[-0.015em] text-balance outline-none"
style={{ fontSize: 'clamp(1.75rem, 1.3rem + 1.8vw, 2.6rem)' }} style={{ fontSize: 'clamp(1.8rem, 1.4rem + 1.4vw, 2.5rem)' }}
> >
{listArticle.title} {listArticle.title}
</h1> </h1>
{listArticle.author && <p className="mt-3 text-paper-muted text-sm">Par {listArticle.author}</p>} {listArticle.author && <p className="mt-2.5 text-paper-muted text-sm">Par {listArticle.author}</p>}
{article.ai_summary ? ( <AiSummary articleId={listArticle.id} summary={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> </header>
{detail ? ( {showImage && (
html ? ( <img
<div className="magazine-content" dangerouslySetInnerHTML={{ __html: html }} /> src={listArticle.image_url}
) : ( alt=""
<p className="text-paper-muted italic">Ce flux ne publie que le titre de l’article.</p> fetchPriority="high"
) decoding="async"
) : isError ? ( referrerPolicy="no-referrer"
<p role="alert" className="text-paper-muted"> className="mt-7 w-full max-h-[48vh] object-cover rounded-2xl bg-carbon-dark"
Impossible de charger l’article.{' '} onError={() => setImgFailed(true)}
<button className="text-nature underline" onClick={() => refetch()}>Réessayer</button> />
</p>
) : (
<div className="space-y-4" aria-busy="true" aria-label="Chargement de l'article">
{[92, 100, 85, 97, 60].map((w, i) => (
<div key={i} className="h-4 rounded bg-carbon-dark animate-pulse" style={{ width: `${w}%` }} />
))}
</div>
)} )}
<div className="mt-7">
{detail ? (
html ? (
<div className="magazine-content" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<p className="text-paper-muted">Ce flux ne publie que le titre de l’article.</p>
)
) : isError ? (
<p role="alert" className="text-paper-muted">
Impossible de charger l’article.{' '}
<button className="text-nature underline" onClick={() => refetch()}>Réessayer</button>
</p>
) : (
<div className="space-y-4" aria-busy="true" aria-label="Chargement de l'article">
{[92, 100, 85, 97, 60].map((w, i) => (
<div key={i} className="h-4 rounded bg-carbon-dark animate-pulse" style={{ width: `${w}%` }} />
))}
</div>
)}
</div>
{listArticle.url && ( {listArticle.url && (
<a <a
href={listArticle.url} href={listArticle.url}
@@ -373,42 +374,52 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
className="mt-10 inline-flex items-center gap-2 text-sm font-semibold text-nature hover:underline" className="mt-10 inline-flex items-center gap-2 text-sm font-semibold text-nature hover:underline"
> >
Lire sur le site d’origine Lire sur le site d’origine
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <Icon name="external" size={16} />
<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> </a>
)} )}
</div>
{/* Bottom navigation: always visible on mobile, "up next" on every size */} {/* Up next (desktop / tablet) */}
<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="hidden md:block mt-12 pt-5 border-t border-line">
<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 <button
onClick={onNext} onClick={onNext}
disabled={!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" className="w-full flex items-center gap-4 text-left px-[18px] py-4 rounded-2xl border border-line bg-carbon hover:border-nature/40 transition-colors disabled:opacity-60 disabled:pointer-events-none"
> >
<span className="min-w-0"> <span className="flex-1 min-w-0">
<span className="block text-xs text-paper-muted">Article suivant</span> <span className="block text-xs font-semibold text-paper-muted">Article suivant · j</span>
<span className="block text-sm font-semibold text-paper-white group-hover:text-nature truncate"> <span className="block mt-1 font-serif text-lg font-semibold leading-snug text-paper-white truncate">
{next ? next.title : onNext ? 'Charger la suite' : 'Fin de la liste'} {next ? next.title : onNext ? 'Charger la suite' : 'Vous êtes à jour — plus rien à lire ici.'}
</span> </span>
</span> </span>
<Chevron dir="right" /> <Icon name="arrowRight" size={20} strokeWidth={2} className="text-nature shrink-0" />
</button> </button>
</div> </div>
</div>
{/* Bottom action bar (phones) */}
<nav
aria-label="Actions sur l’article"
className="md:hidden mt-auto sticky bottom-0 z-20 flex items-center gap-1 px-3 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] bg-carbon-light/95 backdrop-blur border-t border-line"
>
{favButton('tool-btn !w-12 !h-12 !text-paper-white', 24)}
<ShareButton article={article} className="tool-btn !w-12 !h-12 !text-paper-white" iconSize={23} menuAbove />
{listArticle.url && (
<button onClick={openOriginal} className="tool-btn !w-12 !h-12 !text-paper-white" aria-label="Ouvrir l’original">
<Icon name="external" size={22} />
</button>
)}
<span className="flex-1" />
<button
onClick={onNext ?? onClose}
className="inline-flex items-center gap-2 min-h-12 pl-5 pr-4 rounded-full bg-nature text-on-nature text-[15px] font-semibold active:scale-[0.97] transition-transform"
>
{onNext ? 'Suivant' : 'Terminé'}
<Icon name={onNext ? 'arrowRight' : 'check'} size={18} strokeWidth={2.2} />
</button>
</nav> </nav>
</article> </article>
</div> </div>
); );
return createPortal(body, document.body); return overlay ? createPortal(body, document.body) : body;
} }
+28 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { import {
usePrefs, usePrefs,
FONT_SIZE_MAX, FONT_SIZE_MAX,
@@ -7,6 +8,7 @@ import {
type ReaderWidth, type ReaderWidth,
type ThemePref, type ThemePref,
} from '../../stores/prefsStore'; } from '../../stores/prefsStore';
import { useIsMobile } from '../../hooks/useIsMobile';
interface ReaderSettingsProps { interface ReaderSettingsProps {
onClose: () => void; onClose: () => void;
@@ -14,9 +16,9 @@ interface ReaderSettingsProps {
const THEMES: { value: ThemePref; label: string; swatch: string }[] = [ const THEMES: { value: ThemePref; label: string; swatch: string }[] = [
{ value: 'light', label: 'Clair', swatch: '#FFFFFF' }, { value: 'light', label: 'Clair', swatch: '#FFFFFF' },
{ value: 'sepia', label: 'Sépia', swatch: '#F4ECD8' }, { value: 'sepia', label: 'Sépia', swatch: '#F1E7D0' },
{ value: 'dark', label: 'Sombre', swatch: '#161D17' }, { value: 'dark', label: 'Sombre', swatch: '#161B18' },
{ value: 'system', label: 'Auto', swatch: 'linear-gradient(135deg,#fff 50%,#161D17 50%)' }, { value: 'system', label: 'Auto', swatch: 'linear-gradient(135deg,#fff 50%,#161B18 50%)' },
]; ];
const FONTS: { value: ReaderFont; label: string; family: string }[] = [ const FONTS: { value: ReaderFont; label: string; family: string }[] = [
@@ -37,10 +39,14 @@ const LEADINGS = [
{ value: 1.85, label: 'Aéré' }, { value: 1.85, label: 'Aéré' },
]; ];
/** "Aa" popover: theme, font, size, line height and line length. */ /**
* "Aa" panel: theme, font, size, line height and line length. A popover on
* larger screens, a bottom sheet on phones; the text behind updates live.
*/
export function ReaderSettings({ onClose }: ReaderSettingsProps) { export function ReaderSettings({ onClose }: ReaderSettingsProps) {
const prefs = usePrefs(); const prefs = usePrefs();
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
useEffect(() => { useEffect(() => {
panelRef.current?.focus(); panelRef.current?.focus();
@@ -54,7 +60,9 @@ export function ReaderSettings({ onClose }: ReaderSettingsProps) {
return () => window.removeEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]); }, [onClose]);
return ( // The sheet is portalled: the reader toolbar's backdrop blur would
// otherwise become the containing block of its fixed position.
const panel = (
<> <>
<div className="fixed inset-0 z-[60]" onClick={onClose} aria-hidden="true" /> <div className="fixed inset-0 z-[60]" onClick={onClose} aria-hidden="true" />
<div <div
@@ -62,8 +70,21 @@ export function ReaderSettings({ onClose }: ReaderSettingsProps) {
tabIndex={-1} tabIndex={-1}
role="dialog" role="dialog"
aria-label="Réglages de lecture" 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" className={
isMobile
? 'fixed inset-x-0 bottom-0 z-[61] max-h-[85dvh] overflow-y-auto rounded-t-3xl bg-carbon-light border-t border-line px-5 pt-2 pb-[max(1.5rem,env(safe-area-inset-bottom))] space-y-4 shadow-[0_-16px_40px_-16px_rgba(0,0,0,0.45)] animate-rise text-paper-white outline-none'
: '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 outline-none'
}
> >
{isMobile && (
<>
<div className="mx-auto h-[5px] w-10 rounded-full bg-line" aria-hidden="true" />
<div className="flex items-center justify-between">
<h2 className="text-[22px] font-semibold">Affichage</h2>
<button onClick={onClose} className="min-h-11 px-4 rounded-full bg-carbon-dark text-[15px] font-semibold">Terminé</button>
</div>
</>
)}
<fieldset> <fieldset>
<legend className="eyebrow text-paper-muted mb-2">Thème</legend> <legend className="eyebrow text-paper-muted mb-2">Thème</legend>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
@@ -158,4 +179,5 @@ export function ReaderSettings({ onClose }: ReaderSettingsProps) {
</div> </div>
</> </>
); );
return isMobile ? createPortal(panel, document.body) : panel;
} }
+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from 'react';
/** Closes a popover on outside pointer down or Escape, returning focus to its trigger. */
export function useDismiss(open: boolean, close: () => void, refs: React.RefObject<HTMLElement | null>[]) {
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!refs.some((r) => r.current?.contains(e.target as Node))) close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
}
};
document.addEventListener('pointerdown', onDown);
window.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
window.removeEventListener('keydown', onKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, close]);
}
/** Arrow-key navigation between a menu's items. */
export function onMenuKeyDown(e: React.KeyboardEvent<HTMLElement>) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[role^="menuitem"]'));
if (!items.length) return;
e.preventDefault();
const i = items.indexOf(document.activeElement as HTMLElement);
const next =
e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : e.key === 'ArrowDown' ? (i + 1) % items.length : (i - 1 + items.length) % items.length;
items[next]?.focus();
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { feedsApi } from '../api/feeds';
import type { Scope } from '../stores/uiStore';
/** The feed list (shared cache with every screen that shows counters). */
export function useFeeds() {
return useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
}
/** Total unread articles across all feeds. */
export function useTotalUnread() {
const { data } = useFeeds();
return (data ?? []).reduce((n, f) => n + (f.unread_count ?? 0), 0);
}
/** Human name of what the list shows. */
export function useScopeLabel(scope: Scope) {
const { data } = useFeeds();
if (scope === null) return 'À lire';
if (scope === 'favorites') return 'Favoris';
return data?.find((f) => f.id === scope)?.title ?? 'Flux';
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
const query = '(max-width: 768px)'; // Matches Tailwind's md breakpoint (768px): below it is the phone layout.
const query = '(max-width: 767.98px)';
function subscribe(cb: () => void) { function subscribe(cb: () => void) {
const media = window.matchMedia(query); const media = window.matchMedia(query);
+18
View File
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from '../api/auth';
import { useAuthStore, clearUserData } from '../stores/authStore';
/** Ends the session, wipes cached user data and goes back to the login page. */
export function useLogout() {
const qc = useQueryClient();
const logoutStore = useAuthStore((s) => s.logout);
return useMutation({
mutationFn: () => authApi.logout(),
onSettled: async () => {
logoutStore();
qc.clear();
await clearUserData();
window.location.href = '/login';
},
});
}
+87 -45
View File
@@ -1,42 +1,45 @@
@import "tailwindcss"; @import "tailwindcss";
/* ============================================================ /* ============================================================
FlowReader — Design System "Éditorial Botanique" FlowReader — Design System "Calme, lisible, rapide"
Light: Organic Warmth · Sepia: Paper · Dark: Deep Forest Light: Paper · Sepia: Long-form · Dark: Deep Forest (no pure black)
Fonts are self-hosted (see main.tsx) — no third-party requests. Fonts are self-hosted (see main.tsx) — no third-party requests.
============================================================ */ ============================================================ */
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@theme { @theme {
/* Surfaces — warm paper */ /* Surfaces — paper */
--color-carbon: #F7F4EC; /* app background */ --color-carbon: #F6F4EE; /* app background */
--color-carbon-light: #FFFFFF; /* raised surface / cards */ --color-carbon-light: #FFFFFF; /* raised surface / panes / cards */
--color-carbon-dark: #E9E4D8; /* borders / sunken */ --color-carbon-dark: #EFECE4; /* sunken / segmented track */
--color-line: #E4E0D6; /* hairlines between rows and panes */
--color-select: #E8F1EA; /* selected row / current nav item */
/* Brand — forest green */ /* Brand — forest green: actions and the "unread" state */
--color-nature: #15643A; --color-nature: #1B6B43;
--color-nature-light: #1E8A4F; --color-nature-light: #23804F;
--color-nature-dark: #0A2E1B; --color-nature-dark: #0A2E1B;
--color-nature-soft: #E4EFE8; /* AI summary, chips */
--color-on-nature: #FFFFFF; /* text on a nature-filled surface */ --color-on-nature: #FFFFFF; /* text on a nature-filled surface */
/* Accent — terracotta (used sparingly) */ /* Accent — terracotta, reserved for favorites */
--color-earth: #A9502F; --color-earth: #B4532A;
--color-earth-light: #C9714F; --color-earth-light: #C9714F;
--color-on-earth: #FFFFFF; --color-on-earth: #FFFFFF;
/* Ink */ /* Ink */
--color-paper-white: #1B1714; /* primary text */ --color-paper-white: #1A1C19; /* primary text */
--color-paper-muted: #635C53; /* secondary text (≥ 5:1 on paper) */ --color-paper-muted: #5F625B; /* secondary text (≥ 5.6:1 on paper) */
/* Semantic */ /* Semantic */
--color-ring: #1E8A4F; --color-ring: #1B6B43;
--color-danger: #B3390A; --color-danger: #A8401A;
/* Typography */ /* Typography: Newsreader (made for on-screen news reading) + Inter for UI */
--font-serif: "Playfair Display Variable", "Playfair Display", Georgia, serif; --font-serif: "Newsreader Variable", "Newsreader", Georgia, serif;
--font-sans: "Inter Variable", "Inter", system-ui, sans-serif; --font-sans: "Inter Variable", "Inter", system-ui, sans-serif;
--font-reading: "Lora Variable", "Lora", Georgia, serif; --font-reading: "Newsreader Variable", "Newsreader", Georgia, serif;
--font-legible: "Atkinson Hyperlegible", "Inter Variable", system-ui, sans-serif; --font-legible: "Atkinson Hyperlegible", "Inter Variable", system-ui, sans-serif;
/* Radii scale */ /* Radii scale */
@@ -47,20 +50,23 @@
--radius-xl: 1.75rem; --radius-xl: 1.75rem;
/* Shadows */ /* Shadows */
--shadow-soft: 0 2px 8px -2px rgba(27, 23, 20, 0.08), 0 4px 24px -6px rgba(27, 23, 20, 0.06); --shadow-soft: 0 1px 2px rgba(26, 28, 25, 0.05), 0 4px 16px -8px rgba(26, 28, 25, 0.10);
--shadow-lift: 0 12px 32px -12px rgba(27, 23, 20, 0.18); --shadow-lift: 0 12px 32px -12px rgba(26, 28, 25, 0.18);
--shadow-float: 0 24px 60px -20px rgba(10, 46, 27, 0.28); --shadow-float: 0 24px 60px -20px rgba(10, 46, 27, 0.28);
} }
/* ---------- Sepia theme: long-form paper ---------- */ /* ---------- Sepia theme: long-form paper ---------- */
.sepia { .sepia {
--color-carbon: #F4ECD8; --color-carbon: #F1E7D0;
--color-carbon-light: #FBF5E6; --color-carbon-light: #FBF4E4;
--color-carbon-dark: #E6DCC3; --color-carbon-dark: #E8DCC2;
--color-line: #DFD1B5;
--color-select: #ECE6CF;
--color-nature: #2D5F34; --color-nature: #2D5F34;
--color-nature-light: #3B7A44; --color-nature-light: #3B7A44;
--color-nature-dark: #1C3320; --color-nature-dark: #1C3320;
--color-nature-soft: #E6E6CC;
--color-earth: #9A4A2A; --color-earth: #9A4A2A;
@@ -72,24 +78,27 @@
/* ---------- Dark theme: Deep Forest (no pure black / pure white) ---------- */ /* ---------- Dark theme: Deep Forest (no pure black / pure white) ---------- */
.dark { .dark {
--color-carbon: #0E130F; --color-carbon: #0F1311;
--color-carbon-light: #161D17; --color-carbon-light: #161B18;
--color-carbon-dark: #090D0A; --color-carbon-dark: #0B0E0C;
--color-line: #252C28;
--color-select: #18291F;
--color-nature: #34D27B; --color-nature: #45D18A;
--color-nature-light: #5DE89A; --color-nature-light: #6BE0A5;
--color-nature-dark: #0E1C12; --color-nature-dark: #0E1C12;
--color-on-nature: #06210F; /* dark ink on bright green: ~9:1 */ --color-nature-soft: #163024;
--color-on-nature: #062012; /* dark ink on bright green: ~9:1 */
--color-earth: #E08B6B; --color-earth: #E8946F;
--color-earth-light: #E59A7E; --color-earth-light: #EDA585;
--color-on-earth: #2A0F05; --color-on-earth: #2A0F05;
--color-paper-white: #E9E5DA; --color-paper-white: #E7E9E4;
--color-paper-muted: #A39E92; --color-paper-muted: #9BA39C;
--color-ring: #34D27B; --color-ring: #45D18A;
--color-danger: #F87045; --color-danger: #F08A65;
--shadow-soft: 0 2px 10px -2px rgba(0, 0, 0, 0.5); --shadow-soft: 0 2px 10px -2px rgba(0, 0, 0, 0.5);
--shadow-lift: 0 16px 40px -16px rgba(0, 0, 0, 0.6); --shadow-lift: 0 16px 40px -16px rgba(0, 0, 0, 0.6);
@@ -132,6 +141,20 @@
contain-intrinsic-size: auto 440px; contain-intrinsic-size: auto 440px;
} }
/* Same for list rows, which are much shorter than cards. */
@utility cv-row {
content-visibility: auto;
contain-intrinsic-size: auto 150px;
}
/* Room for the home indicator under fixed bottom bars (iOS). */
@utility pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
@utility pt-safe {
padding-top: env(safe-area-inset-top);
}
/* ============================================================ /* ============================================================
Base Base
============================================================ */ ============================================================ */
@@ -139,7 +162,7 @@
*, *,
*::before, *::before,
*::after { *::after {
border-color: color-mix(in oklab, var(--color-paper-muted) 18%, transparent); border-color: var(--color-line);
} }
html { html {
@@ -150,9 +173,6 @@
@apply bg-carbon text-paper-white font-sans antialiased; @apply bg-carbon text-paper-white font-sans antialiased;
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
border-top: 3px solid var(--color-nature);
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 8%, transparent), transparent);
background-repeat: no-repeat;
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
} }
@@ -210,7 +230,7 @@
@apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-semibold @apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-semibold
min-h-11 px-5 py-2.5 rounded-full text-sm 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
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none; active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
} }
@@ -235,12 +255,29 @@
disabled:opacity-40 disabled:pointer-events-none; disabled:opacity-40 disabled:pointer-events-none;
} }
/* Borderless 44px icon button (toolbars, bars) */
.tool-btn {
@apply inline-flex items-center justify-center w-11 h-11 shrink-0 rounded-xl text-paper-muted
transition-colors duration-150 hover:text-paper-white hover:bg-carbon-dark
active:scale-95 disabled:opacity-35 disabled:pointer-events-none;
}
/* Sidebar / sheet navigation row */
.nav-item {
@apply w-full flex items-center gap-3 min-h-11 px-2.5 rounded-xl text-left text-sm text-paper-white
transition-colors duration-150 hover:bg-carbon-dark outline-none focus-visible:bg-carbon-dark;
}
.nav-item[aria-current="page"],
.nav-item[aria-checked="true"] {
@apply bg-select font-semibold hover:bg-select;
}
/* Segmented control (filters, reader settings) */ /* Segmented control (filters, reader settings) */
.segmented { .segmented {
@apply inline-flex p-1 gap-1 rounded-full bg-carbon-dark/70 border border-paper-muted/15; @apply inline-flex p-[3px] gap-0.5 rounded-full bg-carbon-dark;
} }
.segmented > button { .segmented > button {
@apply min-h-9 px-3.5 rounded-full text-xs font-semibold text-paper-muted transition-colors @apply min-h-9 px-3.5 rounded-full text-[13px] font-semibold text-paper-muted transition-colors
hover:text-paper-white; hover:text-paper-white;
} }
.segmented > button[aria-pressed="true"], .segmented > button[aria-pressed="true"],
@@ -251,7 +288,7 @@
/* Inputs */ /* Inputs */
.input-field { .input-field {
@apply w-full bg-carbon-light text-paper-white px-4 py-3 rounded-xl @apply w-full bg-carbon-light text-paper-white px-4 py-3 rounded-xl
border border-paper-muted/25 shadow-sm transition-all duration-200 border border-line transition-all duration-200
placeholder:text-paper-muted placeholder:text-paper-muted
focus:border-nature/50 focus:ring-2 focus:ring-nature/25 outline-none; focus:border-nature/50 focus:ring-2 focus:ring-nature/25 outline-none;
} }
@@ -260,12 +297,12 @@
.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-xs font-semibold text-xs font-semibold
bg-nature/10 text-nature; bg-nature-soft text-nature;
} }
/* Surface card */ /* Surface card */
.surface-card { .surface-card {
@apply bg-carbon-light rounded-2xl border border-paper-muted/12; @apply bg-carbon-light rounded-2xl border border-line;
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
@@ -405,6 +442,7 @@
.animate-rise { animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; } .animate-rise { animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-bounce-slow { animation: bounce-slow 3s infinite; } .animate-bounce-slow { animation: bounce-slow 3s infinite; }
.animate-shake { animation: shake 0.4s ease-in-out; } .animate-shake { animation: shake 0.4s ease-in-out; }
.animate-slide-in { animation: slide-in 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; } from { opacity: 0; }
@@ -418,6 +456,10 @@
0%, 100% { transform: translateY(-5%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 0%, 100% { transform: translateY(-5%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); }
50% { transform: translateY(0); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } 50% { transform: translateY(0); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); }
} }
@keyframes slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
@keyframes shake { @keyframes shake {
0%, 100% { transform: translateX(0); } 0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); } 25% { transform: translateX(-5px); }
+110 -13
View File
@@ -1,34 +1,114 @@
import { Suspense, useState } from 'react'; import { Suspense, useEffect } from 'react';
import { lazyNamed } from '../lib/lazy'; import { lazyNamed } from '../lib/lazy';
import { TopBar } from '../components/TopBar'; import { Sidebar } from '../components/Sidebar';
import { MobileTabBar } from '../components/MobileTabBar';
import { AddFeedModal } from '../components/AddFeedModal';
import { ManageFeedsModal } from '../components/ManageFeedsModal';
import { ShortcutsHelp } from '../components/ShortcutsHelp';
import { DashboardPage } from '../pages/DashboardPage'; import { DashboardPage } from '../pages/DashboardPage';
import { useWebsocket } from '../hooks/useWebsocket'; import { useWebsocket } from '../hooks/useWebsocket';
import { useIsMobile } from '../hooks/useIsMobile';
import { useUI } from '../stores/uiStore';
// Focus mode carries the gesture/animation code: load it only when used. // Focus mode carries the gesture/animation code, search and the feeds tab are
// secondary screens: load them only when used.
const FocusPage = lazyNamed(() => import('../pages/FocusPage'), 'FocusPage'); const FocusPage = lazyNamed(() => import('../pages/FocusPage'), 'FocusPage');
const FeedsPage = lazyNamed(() => import('../pages/FeedsPage'), 'FeedsPage');
const SearchPanel = lazyNamed(() => import('../components/SearchPanel'), 'SearchPanel');
function isTypingTarget(t: EventTarget | null) {
const el = t as HTMLElement | null;
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
}
function Spinner() {
return (
<div className="flex-1 flex items-center justify-center" role="status" aria-label="Chargement">
<div className="w-10 h-10 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
</div>
);
}
/**
* App shell. Desktop (≥1280px): feeds · list · reader. Tablet: list · reader,
* the feeds in a drawer. Phone: one column with a bottom tab bar.
*/
export function RootLayout() { export function RootLayout() {
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null); const isMobile = useIsMobile();
const [isFocusMode, setIsFocusMode] = useState(false); const tab = useUI((s) => s.tab);
const focusOpen = useUI((s) => s.focusOpen);
const searchOpen = useUI((s) => s.searchOpen);
const drawerOpen = useUI((s) => s.drawerOpen);
const dialog = useUI((s) => s.dialog);
const scope = useUI((s) => s.scope);
const setScope = useUI((s) => s.setScope);
const setFocusOpen = useUI((s) => s.setFocusOpen);
const setSearchOpen = useUI((s) => s.setSearchOpen);
const setDrawerOpen = useUI((s) => s.setDrawerOpen);
const openDialog = useUI((s) => s.openDialog);
// One socket for the whole session (not per page mount). // One socket for the whole session (not per page mount).
useWebsocket(); useWebsocket();
// ⌘K / Ctrl+K or "/" opens search from anywhere.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const palette = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k';
const slash = e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey && !isTypingTarget(e.target);
if ((palette || slash) && !focusOpen) {
e.preventDefault();
setSearchOpen(true);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [focusOpen, setSearchOpen]);
// The drawer closes with Escape and when the screen grows past it.
useEffect(() => {
if (!drawerOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setDrawerOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [drawerOpen, setDrawerOpen]);
const showFeeds = isMobile && tab === 'feeds';
return ( return (
<div className="flex flex-col h-dvh bg-carbon overflow-hidden"> <div className="flex h-dvh bg-carbon overflow-hidden">
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[80] btn-primary"> <a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[80] btn-primary">
Aller au contenu Aller au contenu
</a> </a>
{!isFocusMode && (
<TopBar selectedFeedId={selectedFeedId} onSelectFeed={setSelectedFeedId} onEnterFocus={() => setIsFocusMode(true)} /> {/* Desktop: permanent sidebar */}
<aside className="hidden xl:block w-[248px] shrink-0 bg-carbon border-r border-line">
<Sidebar />
</aside>
{/* Tablet: the same sidebar as a drawer */}
{drawerOpen && !isMobile && (
<div className="xl:hidden fixed inset-0 z-40 flex" role="dialog" aria-modal="true" aria-label="Mes flux">
<div className="w-[280px] max-w-[85vw] h-full bg-carbon border-r border-line shadow-[var(--shadow-float)] animate-slide-in">
<Sidebar />
</div>
<button className="flex-1 bg-nature-dark/40 animate-fade-in" onClick={() => setDrawerOpen(false)} aria-label="Fermer la liste des flux" />
</div>
)} )}
<div id="main-content" className="flex-1 flex flex-col min-h-0"> <div id="main-content" className="flex-1 min-w-0 flex flex-col">
{/* Kept mounted in focus mode so the list keeps its scroll position. */} {/* Kept mounted behind focus mode and the feeds tab so the list keeps its scroll position. */}
<DashboardPage selectedFeedId={selectedFeedId} hidden={isFocusMode} /> <DashboardPage hidden={focusOpen || showFeeds} />
{showFeeds && (
<Suspense fallback={<Spinner />}>
<FeedsPage />
</Suspense>
)}
{!focusOpen && <MobileTabBar />}
</div> </div>
{isFocusMode && ( {focusOpen && (
<Suspense <Suspense
fallback={ fallback={
<div className="fixed inset-0 z-50 bg-carbon flex items-center justify-center" role="status" aria-label="Chargement"> <div className="fixed inset-0 z-50 bg-carbon flex items-center justify-center" role="status" aria-label="Chargement">
@@ -36,9 +116,26 @@ export function RootLayout() {
</div> </div>
} }
> >
<FocusPage onExit={() => setIsFocusMode(false)} /> <FocusPage onExit={() => setFocusOpen(false)} />
</Suspense> </Suspense>
)} )}
{searchOpen && (
<Suspense fallback={null}>
<SearchPanel />
</Suspense>
)}
{dialog === 'add' && <AddFeedModal onClose={() => openDialog(null)} />}
{dialog === 'manage' && (
<ManageFeedsModal
onClose={() => openDialog(null)}
onDeleted={(id) => {
if (scope === id) setScope(null);
}}
/>
)}
{dialog === 'help' && <ShortcutsHelp onClose={() => openDialog(null)} />}
</div> </div>
); );
} }
+12
View File
@@ -0,0 +1,12 @@
/** Splits a summary written as a list ("- …", "• …", "1. …") into its points. */
export function toPoints(summary: string): string[] | null {
const lines = summary.split(/\n+/).map((l) => l.trim()).filter(Boolean);
const bullet = /^([-•*–]|\d+[.)])\s+/;
if (lines.length < 2 || !lines.every((l) => bullet.test(l))) return null;
return lines.map((l) => l.replace(bullet, ''));
}
/** One-line form of a summary for previews (list markers removed). */
export function summaryText(summary: string) {
return toPoints(summary)?.join(' · ') ?? summary;
}
+2 -4
View File
@@ -4,10 +4,8 @@ import { registerSW } from 'virtual:pwa-register'
// Self-hosted variable fonts: no third-party request, cached offline by the SW. // Self-hosted variable fonts: no third-party request, cached offline by the SW.
// Browsers only download the unicode-range subsets a page actually uses. // Browsers only download the unicode-range subsets a page actually uses.
import '@fontsource-variable/inter/wght.css' import '@fontsource-variable/inter/wght.css'
import '@fontsource-variable/lora/wght.css' import '@fontsource-variable/newsreader/wght.css'
import '@fontsource-variable/lora/wght-italic.css' import '@fontsource-variable/newsreader/wght-italic.css'
import '@fontsource-variable/playfair-display/wght.css'
import '@fontsource-variable/playfair-display/wght-italic.css'
import '@fontsource/atkinson-hyperlegible/latin-400.css' import '@fontsource/atkinson-hyperlegible/latin-400.css'
import '@fontsource/atkinson-hyperlegible/latin-700.css' import '@fontsource/atkinson-hyperlegible/latin-700.css'
import './index.css' import './index.css'
+323 -161
View File
@@ -1,21 +1,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { keepPreviousData, useInfiniteQuery, useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { articlesApi, cursorAfter, type Article } from '../api/articles'; 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 { ArticleRow } from '../components/ArticleRow';
import { Reader } from '../components/reader/Reader'; import { Reader } from '../components/reader/Reader';
import { ShortcutsHelp } from '../components/ShortcutsHelp'; import { Icon } from '../components/Icon';
import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions'; import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions';
import { useLive } from '../hooks/useWebsocket'; import { useLive } from '../hooks/useWebsocket';
import { prefersReducedMotion } from '../hooks/useIsMobile'; import { prefersReducedMotion, useIsMobile } from '../hooks/useIsMobile';
import { useFeeds, useScopeLabel } from '../hooks/useFeeds';
import { onMenuKeyDown, useDismiss } from '../hooks/useDismiss';
import { usePrefs } from '../stores/prefsStore'; import { usePrefs } from '../stores/prefsStore';
import { useUI } from '../stores/uiStore';
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
interface DashboardPageProps { interface DashboardPageProps {
selectedFeedId: string | null; /** Hidden (kept mounted) while focus mode or the feeds tab is shown, to preserve scroll. */
/** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */
hidden?: boolean; hidden?: boolean;
} }
@@ -24,23 +27,36 @@ 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, hidden }: DashboardPageProps) { /**
* The article list and the reader. Desktop and tablet in list view: the list
* column with the reader pane beside it. Phones and the magazine view: the
* list alone, the reader opening above it.
*/
export function DashboardPage({ hidden }: DashboardPageProps) {
const qc = useQueryClient(); const qc = useQueryClient();
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const [showHelp, setShowHelp] = useState(false); const listRef = useRef<HTMLElement>(null);
const mainRef = useRef<HTMLElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null); const loadMoreRef = useRef<HTMLDivElement>(null);
const [showScrollTop, setShowScrollTop] = useState(false); const [showScrollTop, setShowScrollTop] = useState(false);
const isMobile = useIsMobile();
const scope = useUI((s) => s.scope);
const setDrawerOpen = useUI((s) => s.setDrawerOpen);
const setSearchOpen = useUI((s) => s.setSearchOpen);
const openDialog = useUI((s) => s.openDialog);
const label = useScopeLabel(scope);
const unreadPref = usePrefs((s) => s.unreadOnly); const unreadPref = usePrefs((s) => s.unreadOnly);
const setUnreadOnly = usePrefs((s) => s.setUnreadOnly); const setUnreadOnly = usePrefs((s) => s.setUnreadOnly);
const view = usePrefs((s) => s.view);
const setView = usePrefs((s) => s.setView);
const newCount = useLive((s) => s.newCount); const newCount = useLive((s) => s.newCount);
const clearNew = useLive((s) => s.clearNew); const clearNew = useLive((s) => s.clearNew);
const { setRead, toggleRead, toggleFavorite } = useArticleActions(); const { setRead, toggleRead, toggleFavorite } = useArticleActions();
const favorites = selectedFeedId === 'favorites'; const favorites = scope === 'favorites';
const feedId = favorites ? null : selectedFeedId; const feedId = favorites ? null : scope;
const unreadOnly = unreadPref && !favorites; const unreadOnly = unreadPref && !favorites;
const paneMode = !isMobile && view === 'list';
const listKey = useMemo( const listKey = useMemo(
() => ['articles', { feedId, favorites, unread: unreadOnly }] as const, () => ['articles', { feedId, favorites, unread: unreadOnly }] as const,
@@ -60,12 +76,31 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
const articles = useMemo(() => data?.pages.flat() ?? [], [data]); const articles = useMemo(() => data?.pages.flat() ?? [], [data]);
const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() }); const { data: feeds } = useFeeds();
const unreadTotal = useMemo(() => { const unreadTotal = useMemo(() => {
if (!feeds) return 0; if (!feeds) return 0;
if (feedId) return feeds.find((f) => f.id === feedId)?.unread_count ?? 0; if (feedId) return feeds.find((f) => f.id === feedId)?.unread_count ?? 0;
return feeds.reduce((n, f) => n + (f.unread_count ?? 0), 0); return feeds.reduce((n, f) => n + (f.unread_count ?? 0), 0);
}, [feeds, feedId]); }, [feeds, feedId]);
const currentFeed = feedId ? feeds?.find((f) => f.id === feedId) : undefined;
// A new scope or layout starts at the top of its list, with the reader
// closed (an article from another list must not pop up over this one).
const shown = useRef({ scope, view });
useEffect(() => {
if (shown.current.scope === scope && shown.current.view === view) return;
shown.current = { scope, view };
listRef.current?.scrollTo({ top: 0 });
setParams(
(p) => {
if (!p.has('a')) return p;
const n = new URLSearchParams(p);
n.delete('a');
return n;
},
{ replace: true },
);
}, [scope, view, setParams]);
// ---------- Reader state lives in the URL (?a=<id>) so Back closes it ---------- // ---------- Reader state lives in the URL (?a=<id>) so Back closes it ----------
const openId = params.get('a'); const openId = params.get('a');
@@ -122,6 +157,12 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
if (openIndex > 0) openReader(articles[openIndex - 1], true); if (openIndex > 0) openReader(articles[openIndex - 1], true);
}, [openIndex, articles, openReader]); }, [openIndex, articles, openReader]);
// Keep the open article visible in the list column.
useEffect(() => {
if (!paneMode || openIndex < 0) return;
listRef.current?.querySelector(`[data-index="${openIndex}"]`)?.scrollIntoView({ block: 'nearest' });
}, [paneMode, openIndex]);
// ---------- Infinite scroll (prefetch well before the end) ---------- // ---------- Infinite scroll (prefetch well before the end) ----------
useEffect(() => { useEffect(() => {
const target = loadMoreRef.current; const target = loadMoreRef.current;
@@ -130,21 +171,21 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
(entries) => { (entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
}, },
{ root: mainRef.current, rootMargin: '0px 0px 1200px 0px' }, { root: listRef.current, rootMargin: '0px 0px 1200px 0px' },
); );
observer.observe(target); observer.observe(target);
return () => observer.disconnect(); return () => observer.disconnect();
}, [hasNextPage, fetchNextPage, isFetchingNextPage]); }, [hasNextPage, fetchNextPage, isFetchingNextPage]);
useEffect(() => { useEffect(() => {
const el = mainRef.current; const el = listRef.current;
if (!el) return; if (!el) return;
const onScroll = () => setShowScrollTop(el.scrollTop > 900); const onScroll = () => setShowScrollTop(el.scrollTop > 900);
el.addEventListener('scroll', onScroll, { passive: true }); el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll); return () => el.removeEventListener('scroll', onScroll);
}, []); }, []);
const scrollTop = () => mainRef.current?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); const scrollTop = () => listRef.current?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
const refreshMutation = useMutation({ mutationFn: () => feedsApi.refresh() }); const refreshMutation = useMutation({ mutationFn: () => feedsApi.refresh() });
@@ -155,6 +196,11 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
qc.invalidateQueries({ queryKey: ['feeds'] }); qc.invalidateQueries({ queryKey: ['feeds'] });
}, },
}); });
const markAll = () => {
if (window.confirm(`Marquer ${unreadTotal} article${unreadTotal > 1 ? 's' : ''} comme lu${unreadTotal > 1 ? 's' : ''} ?`)) {
markAllMutation.mutate();
}
};
const showNew = () => { const showNew = () => {
clearNew(); clearNew();
@@ -162,10 +208,10 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
scrollTop(); scrollTop();
}; };
// ---------- Keyboard navigation on the list ---------- // ---------- Keyboard navigation on the list (the reader handles its own) ----------
useEffect(() => { useEffect(() => {
if (hidden || openId) return; if (hidden || openId) return;
const cardAt = (i: number) => mainRef.current?.querySelector<HTMLElement>(`[data-index="${i}"] h2 button`); const cardAt = (i: number) => listRef.current?.querySelector<HTMLElement>(`[data-index="${i}"] h2 button`);
const currentIndex = () => { const currentIndex = () => {
const el = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>('[data-index]'); const el = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>('[data-index]');
return el ? Number(el.dataset.index) : -1; return el ? Number(el.dataset.index) : -1;
@@ -210,7 +256,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
refreshMutation.mutate(); refreshMutation.mutate();
break; break;
case '?': case '?':
setShowHelp(true); openDialog('help');
break; break;
default: default:
return; return;
@@ -219,169 +265,285 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
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, openDialog]);
const subtitle = favorites
? `${articles.length}${hasNextPage ? '+' : ''} favori${articles.length > 1 ? 's' : ''}`
: currentFeed?.fetch_error
? 'Ce flux ne se met plus à jour'
: unreadTotal === 0
? 'Tout est lu'
: `${unreadTotal} non lu${unreadTotal > 1 ? 's' : ''}`;
const position = openIndex >= 0 ? `${openIndex + 1} sur ${articles.length}${hasNextPage ? '+' : ''}` : undefined;
const header = (
<header className="sticky top-0 z-20 bg-carbon md:bg-carbon-light border-b border-line pt-safe">
<div className={`flex items-start gap-0.5 pl-5 pr-2 md:pr-3 pt-3 md:pt-4 ${!paneMode ? 'md:px-8' : ''}`}>
<button onClick={() => setDrawerOpen(true)} className="hidden md:inline-flex xl:hidden tool-btn -ml-3 mr-1" aria-label="Mes flux" title="Mes flux">
<Icon name="menu" size={20} />
</button>
<div className="flex-1 min-w-0 pt-0.5">
<h1 className="text-[34px] md:text-[28px] font-semibold leading-[1.08] tracking-[-0.02em] truncate">{label}</h1>
<p className={`mt-1 text-[13px] ${currentFeed?.fetch_error ? 'text-danger' : 'text-paper-muted'}`} aria-live="polite">
{subtitle}
</p>
</div>
<button onClick={() => setSearchOpen(true)} className="tool-btn !text-paper-white xl:hidden" aria-label="Rechercher" title="Rechercher (/)">
<Icon name="search" size={22} />
</button>
<button onClick={() => refreshMutation.mutate()} disabled={refreshMutation.isPending} className="hidden md:inline-flex tool-btn" aria-label="Actualiser les flux" title="Actualiser (r)">
<Icon name="refresh" size={19} className={refreshMutation.isPending ? 'animate-spin' : ''} />
</button>
{!favorites && unreadTotal > 0 && (
<button onClick={markAll} className="hidden md:inline-flex tool-btn" aria-label="Tout marquer comme lu" title="Tout marquer comme lu">
<Icon name="checkAll" size={19} />
</button>
)}
<MoreMenu favorites={favorites} unreadTotal={unreadTotal} onRefresh={() => refreshMutation.mutate()} onMarkAll={markAll} view={view} setView={setView} />
</div>
<div className={`flex items-center justify-between gap-2 px-5 md:pr-3 pt-2.5 pb-3 ${!paneMode ? 'md:px-8' : ''}`}>
{!favorites ? (
<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>
) : (
<span />
)}
<div className="hidden md:inline-flex gap-0.5" role="group" aria-label="Disposition">
<button
onClick={() => setView('list')}
aria-pressed={view === 'list'}
className={`tool-btn !w-9 !h-9 !rounded-lg ${view === 'list' ? '!bg-carbon-dark !text-paper-white' : ''}`}
aria-label="Vue liste"
title="Vue liste"
>
<Icon name="list" size={18} />
</button>
<button
onClick={() => setView('magazine')}
aria-pressed={view === 'magazine'}
className={`tool-btn !w-9 !h-9 !rounded-lg ${view === 'magazine' ? '!bg-carbon-dark !text-paper-white' : ''}`}
aria-label="Vue magazine"
title="Vue magazine"
>
<Icon name="grid" size={18} />
</button>
</div>
</div>
</header>
);
const reader = openArticle && !hidden && (
<Reader
key={openArticle.id}
article={openArticle}
variant={paneMode ? 'pane' : 'overlay'}
backLabel={label}
position={position}
next={openIndex >= 0 ? articles[openIndex + 1] : undefined}
hasPrev={openIndex > 0}
onClose={closeReader}
onNext={openIndex >= 0 && (openIndex + 1 < articles.length || hasNextPage) ? goNext : undefined}
onPrev={goPrev}
/>
);
return ( return (
<main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}> <div className={`flex-1 min-w-0 min-h-0 flex ${hidden ? 'hidden' : ''}`}>
<div className="max-w-[2400px] mx-auto px-4 sm:px-6 md:px-12 py-6 md:py-8"> <section
{/* The page title is the feed selector in the top bar. */} ref={listRef}
<div className="mb-6 flex flex-wrap items-center justify-between gap-x-6 gap-y-3"> aria-label="Articles"
<p className="text-paper-muted text-sm" aria-live="polite"> aria-busy={isFetching}
{favorites className={`relative min-h-0 overflow-y-auto overscroll-contain bg-carbon md:bg-carbon-light ${
? `${articles.length}${hasNextPage ? '+' : ''} favori${articles.length > 1 ? 's' : ''}` paneMode ? 'w-[min(400px,42vw)] shrink-0 border-r border-line' : 'flex-1 min-w-0'
: unreadTotal === 0 }`}
? 'Tout est lu' >
: `${unreadTotal} article${unreadTotal > 1 ? 's' : ''} non lu${unreadTotal > 1 ? 's' : ''}`} {header}
</p>
<div className="flex items-center gap-2">
{!favorites && (
<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>
)}
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
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
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>
)}
</div>
</div>
{/* New articles are announced, never inserted under the reader's eyes */} {/* New articles are announced, never inserted under the reader's eyes */}
{newCount > 0 && ( {newCount > 0 && (
<div className="sticky top-3 z-30 flex justify-center mb-6 pointer-events-none"> <div className="sticky top-[132px] md:top-[124px] z-10 flex justify-center pt-3 pointer-events-none">
<button onClick={showNew} className="btn-primary pointer-events-auto shadow-lg normal-case tracking-normal text-sm"> <button onClick={showNew} className="btn-primary !min-h-9 !px-4 pointer-events-auto shadow-[var(--shadow-lift)]">
Afficher {newCount} nouvel{newCount > 1 ? 's' : ''} article{newCount > 1 ? 's' : ''} <Icon name="arrowUp" size={14} strokeWidth={2.2} />
{newCount} nouvel{newCount > 1 ? 's' : ''} article{newCount > 1 ? 's' : ''}
</button> </button>
</div> </div>
)} )}
{/* Grid */} <div className={view === 'magazine' ? 'max-w-[2400px] mx-auto px-4 sm:px-6 md:px-8 py-6' : 'bg-carbon-light'}>
{isPending ? ( {isPending ? (
<div className="article-grid" aria-label="Chargement des articles" role="status"> <div role="status" aria-label="Chargement des articles">
{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="flex gap-3 pl-8 pr-4 py-4 border-b border-line">
<div className="aspect-[16/9] bg-carbon-dark animate-pulse" /> <div className="flex-1 space-y-2.5">
<div className="p-5 space-y-3"> <div className="h-3 w-1/3 rounded bg-carbon-dark animate-pulse" />
<div className="h-5 w-4/5 rounded bg-carbon-dark animate-pulse" /> <div className="h-4 w-11/12 rounded bg-carbon-dark animate-pulse" />
<div className="h-4 w-full rounded bg-carbon-dark animate-pulse" /> <div className="h-4 w-2/3 rounded bg-carbon-dark animate-pulse" />
<div className="h-4 w-2/3 rounded bg-carbon-dark animate-pulse" /> </div>
<div className="w-16 h-16 rounded-xl bg-carbon-dark animate-pulse" />
</div> </div>
</div>
))}
</div>
) : 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">
<p className="text-paper-white font-serif text-xl">
{navigator.onLine ? 'Impossible de charger vos articles.' : 'Vous êtes hors ligne.'}
</p>
<button onClick={() => refetch()} className="btn-secondary">Réessayer</button>
</div>
) : articles.length > 0 ? (
<>
<div
className={`article-grid transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}
>
{articles.map((article, i) => (
<ArticleCard
key={article.id}
index={i}
article={article}
onOpen={openReader}
onToggleRead={toggleRead}
onToggleFavorite={toggleFavorite}
/>
))} ))}
</div> </div>
) : isError && articles.length === 0 ? (
<div ref={loadMoreRef} className="mt-14 py-8 flex flex-col items-center gap-3"> <div role="alert" className="flex flex-col items-center justify-center py-24 text-center px-6 gap-4">
{isFetchingNextPage ? ( <p className="text-paper-white font-serif text-xl">
<div className="w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" role="status" aria-label="Chargement" /> {navigator.onLine ? 'Impossible de charger vos articles.' : 'Vous êtes hors ligne.'}
) : !hasNextPage ? ( </p>
<div className="flex items-center gap-3"> <button onClick={() => refetch()} className="btn-secondary">Réessayer</button>
<span className="h-px w-16 bg-nature/25" /> </div>
<p className="text-sm text-paper-muted">Fin de la liste</p> ) : articles.length > 0 ? (
<span className="h-px w-16 bg-nature/25" /> <>
{view === 'magazine' ? (
<div className={`article-grid transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}>
{articles.map((article, i) => (
<ArticleCard
key={article.id}
index={i}
article={article}
onOpen={openReader}
onToggleRead={toggleRead}
onToggleFavorite={toggleFavorite}
/>
))}
</div> </div>
) : (
<div className={`transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}>
{articles.map((article, i) => (
<ArticleRow
key={article.id}
index={i}
article={article}
selected={paneMode && article.id === openId}
onOpen={openReader}
onToggleRead={toggleRead}
onToggleFavorite={toggleFavorite}
/>
))}
</div>
)}
<div ref={loadMoreRef} className="py-8 flex flex-col items-center gap-3">
{isFetchingNextPage ? (
<div className="w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" role="status" aria-label="Chargement" />
) : !hasNextPage ? (
<p className="text-[13px] text-paper-muted">Fin de la liste</p>
) : null}
</div>
</>
) : (
<div className="flex flex-col items-center justify-center py-20 text-center px-8 gap-3">
<span aria-hidden="true" className="w-16 h-16 rounded-full bg-nature-soft text-nature flex items-center justify-center">
<Icon name={favorites ? 'star' : 'check'} size={28} strokeWidth={2} />
</span>
<p className="text-paper-white font-serif text-2xl font-semibold">
{favorites ? 'Aucun favori' : unreadOnly && (feeds?.length ?? 0) > 0 ? 'Tout est lu' : 'Aucun article'}
</p>
<p className="text-paper-muted text-sm max-w-xs">
{favorites
? 'Touchez l’étoile d’un article pour le retrouver ici.'
: unreadOnly && (feeds?.length ?? 0) > 0
? 'Les nouveaux articles apparaîtront ici.'
: 'Ajoutez un flux pour commencer.'}
</p>
{unreadOnly && (feeds?.length ?? 0) > 0 ? (
<button onClick={() => setUnreadOnly(false)} className="btn-secondary mt-2">Voir tous les articles</button>
) : !favorites && (feeds?.length ?? 0) === 0 ? (
<button onClick={() => openDialog('add')} className="btn-primary mt-2">Ajouter un flux</button>
) : null} ) : null}
</div> </div>
</> )}
) : ( </div>
<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 text-2xl">
{favorites
? 'Aucun favori'
: unreadOnly && (feeds?.length ?? 0) > 0
? 'Tout est lu'
: 'Aucun article'}
</p>
<p className="text-paper-muted text-sm">
{favorites
? 'Touchez l’étoile d’un article pour le retrouver ici.'
: unreadOnly && (feeds?.length ?? 0) > 0
? 'Les nouveaux articles apparaîtront ici.'
: 'Ajoutez un flux avec le bouton +.'}
</p>
{unreadOnly && (feeds?.length ?? 0) > 0 && (
<button onClick={() => setUnreadOnly(false)} className="btn-secondary mt-2">Voir tous les articles</button>
)}
</div>
)}
</div>
{/* Back to top */} {/* Back to top */}
<button <div className="sticky bottom-5 z-20 flex justify-end pr-5 pointer-events-none">
onClick={scrollTop} <button
className={`fixed bottom-6 right-6 z-40 icon-btn !w-12 !h-12 bg-carbon-light shadow-lg transition-all duration-300 ${ onClick={scrollTop}
showScrollTop ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none' className={`pointer-events-auto icon-btn !w-12 !h-12 bg-carbon-light shadow-[var(--shadow-lift)] transition-all duration-300 ${
}`} showScrollTop ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 !pointer-events-none'
aria-label="Retour en haut" }`}
tabIndex={showScrollTop ? 0 : -1} aria-label="Retour en haut"
> tabIndex={showScrollTop ? 0 : -1}
<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.5} d="M5 10l7-7m0 0l7 7m-7-7v18" /> <Icon name="arrowUp" size={20} strokeWidth={2.2} />
</svg> </button>
</button> </div>
</section>
{openArticle && !hidden && ( {paneMode && (
<Reader <div className="flex-1 min-w-0 min-h-0 bg-carbon-light">
key={openArticle.id} {reader || (
article={openArticle} <div className="h-full flex flex-col items-center justify-center gap-3 px-8 text-center">
next={openIndex >= 0 ? articles[openIndex + 1] : undefined} <span aria-hidden="true" className="w-16 h-16 rounded-full bg-carbon-dark text-paper-muted flex items-center justify-center">
hasPrev={openIndex > 0} <Icon name="book" size={28} />
onClose={closeReader} </span>
onNext={openIndex >= 0 && (openIndex + 1 < articles.length || hasNextPage) ? goNext : undefined} <p className="font-serif text-xl font-semibold text-paper-white">Choisissez un article</p>
onPrev={goPrev} <p className="text-sm text-paper-muted">
/> <kbd className="kbd">j</kbd> <kbd className="kbd">k</kbd> pour parcourir la liste, <kbd className="kbd">o</kbd> pour ouvrir.
</p>
</div>
)}
</div>
)} )}
{!paneMode && reader}
</div>
);
}
{showHelp && <ShortcutsHelp onClose={() => setShowHelp(false)} />} /** "⋯" menu of the list header on phones. */
</main> function MoreMenu({
favorites,
unreadTotal,
onRefresh,
onMarkAll,
view,
setView,
}: {
favorites: boolean;
unreadTotal: number;
onRefresh: () => void;
onMarkAll: () => void;
view: 'list' | 'magazine';
setView: (v: 'list' | 'magazine') => void;
}) {
const [open, setOpen] = useState(false);
const btn = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const close = () => {
setOpen(false);
btn.current?.focus();
};
useDismiss(open, close, [btn, panel]);
const item = 'w-full text-left flex items-center gap-3 px-3 min-h-12 rounded-lg text-[15px] text-paper-white active:bg-carbon-dark focus-visible:bg-carbon-dark outline-none';
const run = (fn: () => void) => () => {
setOpen(false);
fn();
};
return (
<div className="relative md:hidden">
<button ref={btn} onClick={() => setOpen((o) => !o)} className="tool-btn !text-paper-white" aria-haspopup="menu" aria-expanded={open} aria-label="Plus d’actions">
<Icon name="more" size={22} />
</button>
{open && (
<div ref={panel} role="menu" aria-label="Actions" onKeyDown={onMenuKeyDown} className="absolute right-0 top-full mt-1 z-50 w-64 surface-card p-1.5 animate-fade-in">
<button role="menuitem" autoFocus className={item} onClick={run(onRefresh)}>
<Icon name="refresh" size={19} className="text-paper-muted" />
Actualiser les flux
</button>
{!favorites && unreadTotal > 0 && (
<button role="menuitem" className={item} onClick={run(onMarkAll)}>
<Icon name="checkAll" size={19} className="text-paper-muted" />
Tout marquer comme lu
</button>
)}
<button role="menuitem" className={item} onClick={run(() => setView(view === 'list' ? 'magazine' : 'list'))}>
<Icon name={view === 'list' ? 'grid' : 'list'} size={19} className="text-paper-muted" />
{view === 'list' ? 'Vue magazine' : 'Vue liste'}
</button>
</div>
)}
</div>
); );
} }
+143
View File
@@ -0,0 +1,143 @@
import { useState } from 'react';
import { usePrefs, type ThemePref } from '../stores/prefsStore';
import { useUI } from '../stores/uiStore';
import { useAuthStore } from '../stores/authStore';
import { useFeeds, useTotalUnread } from '../hooks/useFeeds';
import { useLogout } from '../hooks/useLogout';
import { Icon, type IconName } from '../components/Icon';
import { FeedAvatar } from '../components/FeedAvatar';
import { formatRelative } from '../lib/format';
const THEMES: { value: ThemePref; label: string }[] = [
{ value: 'light', label: 'Clair' },
{ value: 'sepia', label: 'Sépia' },
{ value: 'dark', label: 'Sombre' },
{ value: 'system', label: 'Auto' },
];
const group = 'rounded-2xl bg-carbon-light border border-line overflow-hidden divide-y divide-line';
const rowBtn = 'w-full flex items-center gap-3.5 min-h-[52px] px-3.5 text-left text-[15px] text-paper-white active:bg-carbon-dark';
/** "Flux" tab on phones: views, subscriptions and account actions. */
export function FeedsPage() {
const { data: feeds, isPending } = useFeeds();
const unread = useTotalUnread();
const setScope = useUI((s) => s.setScope);
const openDialog = useUI((s) => s.openDialog);
const theme = usePrefs((s) => s.theme);
const setTheme = usePrefs((s) => s.setTheme);
const user = useAuthStore((s) => s.user);
const logout = useLogout();
const [filter, setFilter] = useState('');
const shown = (feeds ?? []).filter((f) => f.title.toLowerCase().includes(filter.trim().toLowerCase()));
const views: { label: string; icon: IconName; count?: number; onClick: () => void }[] = [
{ label: 'À lire', icon: 'inbox', count: unread, onClick: () => setScope(null) },
{ label: 'Favoris', icon: 'star', onClick: () => setScope('favorites') },
];
return (
<main className="flex-1 min-h-0 overflow-y-auto overscroll-contain bg-carbon pt-safe">
<header className="px-5 pt-3 pb-3 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<h1 className="text-[34px] font-semibold leading-tight">Mes flux</h1>
<button onClick={() => openDialog('add')} className="btn-primary !pl-3 !pr-4 !text-[15px]">
<Icon name="plus" size={18} strokeWidth={2.2} />
Ajouter
</button>
</div>
{(feeds?.length ?? 0) > 6 && (
<div className="relative">
<Icon name="search" size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-paper-muted" />
<label htmlFor="feeds-filter" className="sr-only">Filtrer mes flux</label>
<input
id="feeds-filter"
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filtrer mes flux"
className="input-field !py-2.5 !pl-10"
/>
</div>
)}
</header>
<div className="px-4 pb-8 flex flex-col gap-2">
<h2 className="font-sans px-1 text-[13px] font-semibold text-paper-muted">Vues</h2>
<ul role="list" className={group}>
{views.map((v) => (
<li key={v.label}>
<button onClick={v.onClick} className={rowBtn}>
<span aria-hidden="true" className="w-8 h-8 rounded-[9px] bg-nature-soft text-nature flex items-center justify-center">
<Icon name={v.icon} size={18} strokeWidth={1.9} />
</span>
<span className="flex-1 font-semibold">{v.label}</span>
{!!v.count && <span className="text-sm text-paper-muted tabular-nums">{v.count}</span>}
<Icon name="chevronRight" size={18} strokeWidth={2} className="text-paper-muted" />
</button>
</li>
))}
</ul>
<h2 className="font-sans mt-4 px-1 text-[13px] font-semibold text-paper-muted">
Abonnements{feeds ? ` · ${feeds.length}` : ''}
</h2>
{isPending ? (
<div className={`${group} h-40 animate-pulse`} aria-label="Chargement" role="status" />
) : shown.length === 0 ? (
<p className="px-1 text-sm text-paper-muted">
{filter ? 'Aucun flux ne correspond.' : 'Aucun flux pour l’instant : ajoutez-en un ou importez un fichier OPML.'}
</p>
) : (
<ul role="list" className={group}>
{shown.map((f) => (
<li key={f.id}>
<button onClick={() => setScope(f.id)} className={`${rowBtn} !min-h-[60px] py-2`}>
<FeedAvatar id={f.id} title={f.title} size={32} />
<span className="flex-1 min-w-0">
<span className="block font-semibold truncate">{f.title}</span>
<span className={`block text-[13px] truncate ${f.fetch_error ? 'text-danger' : 'text-paper-muted'}`}>
{f.fetch_error
? 'Ne se met plus à jour'
: f.last_fetched_at
? `Mis à jour ${formatRelative(f.last_fetched_at)}`
: f.url}
</span>
</span>
{f.fetch_error ? (
<Icon name="alert" size={20} className="text-danger" label="Erreur" />
) : (f.unread_count ?? 0) > 0 ? (
<span className="min-w-7 h-6 px-2 rounded-full bg-carbon-dark text-xs font-bold flex items-center justify-center tabular-nums">
{f.unread_count}
</span>
) : null}
</button>
</li>
))}
</ul>
)}
<h2 className="font-sans mt-4 px-1 text-[13px] font-semibold text-paper-muted">Thème</h2>
<div className="segmented w-full" role="group" aria-label="Thème">
{THEMES.map((t) => (
<button key={t.value} className="flex-1" aria-pressed={theme === t.value} onClick={() => setTheme(t.value)}>
{t.label}
</button>
))}
</div>
<ul role="list" className={`${group} mt-4`}>
<li><button onClick={() => openDialog('manage')} className={rowBtn}>Renommer ou supprimer des flux</button></li>
<li><button onClick={() => openDialog('add')} className={rowBtn}>Importer un fichier OPML</button></li>
<li><a href="/api/v1/feeds/export/opml" className={rowBtn}>Exporter mes flux (OPML)</a></li>
<li>
<button onClick={() => logout.mutate()} className={`${rowBtn} !text-danger font-semibold`}>
Se déconnecter
</button>
</li>
</ul>
{user && <p className="px-1 pt-1 text-xs text-paper-muted text-center">Connecté en tant que {user.email}</p>}
</div>
</main>
);
}
+8 -13
View File
@@ -4,6 +4,7 @@ import { MotionConfig } from 'framer-motion';
import { articlesApi } from '../api/articles'; import { articlesApi } from '../api/articles';
import { FocusCardStack } from '../components/focus/FocusCardStack'; import { FocusCardStack } from '../components/focus/FocusCardStack';
import { FocusEmptyState } from '../components/focus/FocusEmptyState'; import { FocusEmptyState } from '../components/focus/FocusEmptyState';
import { Icon } from '../components/Icon';
interface FocusPageProps { interface FocusPageProps {
onExit: () => void; onExit: () => void;
@@ -38,7 +39,7 @@ export function FocusPage({ onExit }: FocusPageProps) {
} else if (isError) { } else if (isError) {
body = ( body = (
<div role="alert" className="flex-1 flex flex-col items-center justify-center gap-4 text-center"> <div role="alert" className="flex-1 flex flex-col items-center justify-center gap-4 text-center">
<p className="font-serif italic text-xl text-paper-white">Impossible de charger vos articles.</p> <p className="font-serif text-xl text-paper-white">Impossible de charger vos articles.</p>
<button onClick={() => refetch()} className="btn-secondary">Réessayer</button> <button onClick={() => refetch()} className="btn-secondary">Réessayer</button>
</div> </div>
); );
@@ -46,7 +47,7 @@ export function FocusPage({ onExit }: FocusPageProps) {
body = <FocusEmptyState onBack={onExit} />; body = <FocusEmptyState onBack={onExit} />;
} else { } else {
body = ( body = (
<main className="flex-1 flex items-center justify-center p-4 min-h-0"> <main className="flex-1 flex items-center justify-center px-5 pt-2 min-h-0">
<FocusCardStack articles={deck} onEmpty={() => setIsComplete(true)} onExit={onExit} /> <FocusCardStack articles={deck} onEmpty={() => setIsComplete(true)} onExit={onExit} />
</main> </main>
); );
@@ -55,18 +56,12 @@ export function FocusPage({ onExit }: FocusPageProps) {
return ( return (
<MotionConfig reducedMotion="user"> <MotionConfig reducedMotion="user">
<div className="fixed inset-0 z-50 bg-carbon flex flex-col animate-fade-in" role="region" aria-label="Mode Focus"> <div className="fixed inset-0 z-50 bg-carbon flex flex-col animate-fade-in" role="region" aria-label="Mode Focus">
<header className="flex justify-between items-center p-4 sm:p-6 shrink-0"> <header className="flex items-center gap-2 px-2 sm:px-4 pt-[max(0.5rem,env(safe-area-inset-top))] pb-1 shrink-0">
<div className="flex items-center gap-2"> <button onClick={onExit} className="tool-btn !text-paper-white" aria-label="Quitter le mode Focus" title="Quitter le mode Focus (Échap)">
<span className="w-2 h-2 rounded-full bg-earth animate-pulse" aria-hidden="true" /> <Icon name="close" size={22} strokeWidth={2} />
<span className="eyebrow text-paper-white">Mode Focus</span>
</div>
<button onClick={onExit} className="btn-secondary" title="Quitter le mode Focus (Échap)">
<span className="hidden sm:inline">Tableau de bord</span>
<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="M6 18L18 6M6 6l12 12" />
</svg>
<span className="sr-only sm:hidden">Quitter</span>
</button> </button>
<h1 className="flex-1 text-center font-sans text-[15px] font-bold text-paper-white">Mode Focus</h1>
<span className="w-11" aria-hidden="true" />
</header> </header>
{body} {body}
</div> </div>
+14 -3
View File
@@ -4,6 +4,8 @@ import { persist } from 'zustand/middleware';
export type ThemePref = 'system' | 'light' | 'sepia' | 'dark'; export type ThemePref = 'system' | 'light' | 'sepia' | 'dark';
export type ReaderFont = 'serif' | 'sans' | 'legible'; export type ReaderFont = 'serif' | 'sans' | 'legible';
export type ReaderWidth = 'narrow' | 'medium' | 'wide'; export type ReaderWidth = 'narrow' | 'medium' | 'wide';
/** List rows with a thumbnail, or a magazine grid of cards. */
export type ListView = 'list' | 'magazine';
interface PrefsState { interface PrefsState {
theme: ThemePref; theme: ThemePref;
@@ -14,12 +16,17 @@ interface PrefsState {
font: ReaderFont; font: ReaderFont;
/** Dashboard filter: show only unread articles. */ /** Dashboard filter: show only unread articles. */
unreadOnly: boolean; unreadOnly: boolean;
view: ListView;
/** AI summary block expanded at the top of the reader. */
summaryOpen: boolean;
setTheme: (t: ThemePref) => void; setTheme: (t: ThemePref) => void;
setFontSize: (n: number) => void; setFontSize: (n: number) => void;
setLineHeight: (n: number) => void; setLineHeight: (n: number) => void;
setWidth: (w: ReaderWidth) => void; setWidth: (w: ReaderWidth) => void;
setFont: (f: ReaderFont) => void; setFont: (f: ReaderFont) => void;
setUnreadOnly: (v: boolean) => void; setUnreadOnly: (v: boolean) => void;
setView: (v: ListView) => void;
setSummaryOpen: (v: boolean) => void;
resetReader: () => void; resetReader: () => void;
} }
@@ -30,9 +37,9 @@ export const FONT_SIZE_MIN = 15;
export const FONT_SIZE_MAX = 26; export const FONT_SIZE_MAX = 26;
const THEME_COLORS: Record<Exclude<ThemePref, 'system'>, string> = { const THEME_COLORS: Record<Exclude<ThemePref, 'system'>, string> = {
light: '#F7F4EC', light: '#F6F4EE',
sepia: '#F4ECD8', sepia: '#F1E7D0',
dark: '#121612', dark: '#0F1311',
}; };
function resolveTheme(pref: ThemePref): Exclude<ThemePref, 'system'> { function resolveTheme(pref: ThemePref): Exclude<ThemePref, 'system'> {
@@ -62,6 +69,8 @@ export const usePrefs = create<PrefsState>()(
theme: 'system', theme: 'system',
...READER_DEFAULTS, ...READER_DEFAULTS,
unreadOnly: true, unreadOnly: true,
view: 'list',
summaryOpen: true,
setTheme: (theme) => { setTheme: (theme) => {
applyTheme(theme); applyTheme(theme);
set({ theme }); set({ theme });
@@ -71,6 +80,8 @@ export const usePrefs = create<PrefsState>()(
setWidth: (width) => set({ width }), setWidth: (width) => set({ width }),
setFont: (font) => set({ font }), setFont: (font) => set({ font }),
setUnreadOnly: (unreadOnly) => set({ unreadOnly }), setUnreadOnly: (unreadOnly) => set({ unreadOnly }),
setView: (view) => set({ view }),
setSummaryOpen: (summaryOpen) => set({ summaryOpen }),
resetReader: () => set(READER_DEFAULTS), resetReader: () => set(READER_DEFAULTS),
}), }),
{ {
+40
View File
@@ -0,0 +1,40 @@
import { create } from 'zustand';
/** What the article list shows: everything, the favorites, or one feed (its id). */
export type Scope = null | 'favorites' | string;
type Dialog = null | 'add' | 'manage' | 'help';
interface UIState {
scope: Scope;
/** Mobile only: the "Flux" tab replaces the list. */
tab: 'list' | 'feeds';
focusOpen: boolean;
searchOpen: boolean;
/** Tablet only: the feed list slides in as a drawer. */
drawerOpen: boolean;
dialog: Dialog;
setScope: (scope: Scope) => void;
setTab: (tab: 'list' | 'feeds') => void;
setFocusOpen: (v: boolean) => void;
setSearchOpen: (v: boolean) => void;
setDrawerOpen: (v: boolean) => void;
openDialog: (d: Dialog) => void;
}
/** Navigation state of the reader app (not persisted). */
export const useUI = create<UIState>()((set) => ({
scope: null,
tab: 'list',
focusOpen: false,
searchOpen: false,
drawerOpen: false,
dialog: null,
// Choosing what to read always brings the list back.
setScope: (scope) => set({ scope, tab: 'list', drawerOpen: false }),
setTab: (tab) => set({ tab }),
setFocusOpen: (focusOpen) => set({ focusOpen, drawerOpen: false }),
setSearchOpen: (searchOpen) => set({ searchOpen, drawerOpen: false }),
setDrawerOpen: (drawerOpen) => set({ drawerOpen }),
openDialog: (dialog) => set({ dialog, drawerOpen: false }),
}));
+2 -2
View File
@@ -17,8 +17,8 @@ export default defineConfig({
short_name: 'FlowReader', short_name: 'FlowReader',
description: 'Lecteur RSS minimaliste et rapide', description: 'Lecteur RSS minimaliste et rapide',
lang: 'fr', lang: 'fr',
theme_color: '#15643A', theme_color: '#1B6B43',
background_color: '#F7F4EC', background_color: '#F6F4EE',
display: 'standalone', display: 'standalone',
start_url: '/', start_url: '/',
icons: [ icons: [