mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-21 06:08:06 +02:00
feat(web): redesign — three-pane desktop, mobile tab bar, search
Implements the approved design proposal across every screen. - Layout: feeds · list · reader panes on desktop (≥1280px), list + reader with the feeds in a drawer on tablets, one column with a bottom tab bar (À lire, Focus, Favoris, Flux) on phones. - List: dense rows with an unread dot, feed initial and square thumbnail instead of large cards; touch swipe reveals the action (→ lu, ← favori). The magazine grid stays available as a view toggle. - Reader: inline pane on desktop, full screen on phones with a bottom action bar (favori, partager, original, Suivant); collapsible "Résumé IA" block; "Aa" settings become a bottom sheet on phones. - Search: the existing /articles/search endpoint gets a UI — ⌘K / "/" palette on desktop, full screen on phones, with recent searches. - Mobile "Mes flux" screen: views, subscriptions with errors, theme, OPML import/export, logout. - Focus mode: progress bar, counter and labelled buttons. - Tokens: new paper / deep-forest palette (all text ≥4.5:1), Newsreader + Inter replace Playfair + Lora (two families instead of four), shared Icon and FeedAvatar components. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UPyU2LbJ2rTrsgZqQKhH3j
This commit is contained in:
38 files changed
+2019
-963
No files matched your search
+1
-1
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="description"
|
||||
content="FlowReader - Votre édition quotidienne pour une lecture plaisir et sans distractions." />
|
||||
|
||||
Generated
+4
-14
@@ -9,8 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/lora": "^5.3.0",
|
||||
"@fontsource-variable/playfair-display": "^5.3.0",
|
||||
"@fontsource-variable/newsreader": "^5.3.0",
|
||||
"@fontsource/atkinson-hyperlegible": "^5.3.0",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"framer-motion": "^12.34.0",
|
||||
@@ -2257,19 +2256,10 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/lora": {
|
||||
"node_modules/@fontsource-variable/newsreader": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lora/-/lora-5.3.0.tgz",
|
||||
"integrity": "sha512-2ph8KiZ8Bn141hWuC3ErtpJ+Ui8p2MJQn8TJNRSRmLmnCIOCtHfiBM9R7WLRPeUG/q6hXyHOxWQ8DvRSX+ixvQ==",
|
||||
"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==",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/newsreader/-/newsreader-5.3.0.tgz",
|
||||
"integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
|
||||
+1
-2
@@ -11,8 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/lora": "^5.3.0",
|
||||
"@fontsource-variable/playfair-display": "^5.3.0",
|
||||
"@fontsource-variable/newsreader": "^5.3.0",
|
||||
"@fontsource/atkinson-hyperlegible": "^5.3.0",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"framer-motion": "^12.34.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#166534"/>
|
||||
<text x="16" y="23" font-family="Playfair Display, serif" font-size="18" font-weight="900" fill="#F9F7F1" text-anchor="middle">F</text>
|
||||
<rect width="32" height="32" rx="8" fill="#1B6B43"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 336 B After Width: | Height: | Size: 339 B |
@@ -51,15 +51,15 @@ export function AddFeedModal({ onClose }: AddFeedModalProps) {
|
||||
|
||||
return (
|
||||
<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}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-feed-title"
|
||||
>
|
||||
<div className="w-full max-w-md surface-card p-6 sm:p-8 animate-rise" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="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">
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
@@ -2,12 +2,14 @@ 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 ArticleCardProps {
|
||||
article: Article;
|
||||
/** Position in the list, used by keyboard navigation (j/k). */
|
||||
index: number;
|
||||
isCurrent?: boolean;
|
||||
onOpen: (article: Article) => void;
|
||||
onToggleRead: (article: Article) => void;
|
||||
onToggleFavorite: (article: Article) => void;
|
||||
@@ -17,13 +19,14 @@ interface ArticleCardProps {
|
||||
function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
||||
const initial = (feed || title).trim().charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="w-full h-full bg-nature/12 flex items-center justify-center" aria-hidden="true">
|
||||
<span className="font-serif italic text-nature/45 text-7xl leading-none select-none">{initial}</span>
|
||||
<div className="w-full h-full bg-nature-soft flex items-center justify-center" aria-hidden="true">
|
||||
<span className="font-serif text-nature/50 text-7xl leading-none select-none">{initial}</span>
|
||||
</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 [imgFailed, setImgFailed] = 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 date = article.published_at || article.created_at;
|
||||
const unread = !article.is_read;
|
||||
|
||||
return (
|
||||
<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'}`}>
|
||||
{article.is_read ? 'Non lu' : 'Lu'}
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<article
|
||||
{...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]
|
||||
${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`}
|
||||
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"
|
||||
style={{
|
||||
transform: offset ? `translateX(${offset}px)` : undefined,
|
||||
transition: offset ? 'none' : 'transform 0.35s cubic-bezier(0.22,1,0.36,1)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
transition: offset ? 'none' : 'transform 0.35s cubic-bezier(0.2,0.8,0.2,1)',
|
||||
}}
|
||||
>
|
||||
{/* 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 ? (
|
||||
<img
|
||||
src={article.image_url}
|
||||
@@ -81,7 +81,7 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
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'}`}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
onError={() => setImgFailed(true)}
|
||||
@@ -89,25 +89,21 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* 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
|
||||
className={`text-xl font-serif leading-snug text-balance transition-colors group-hover:text-nature
|
||||
${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-semibold'}`}
|
||||
className={`text-xl font-serif leading-[1.25] text-balance transition-colors group-hover:text-nature
|
||||
${unread ? 'text-paper-white font-semibold' : 'text-paper-muted font-medium'}`}
|
||||
>
|
||||
{/* Stretched button: the whole card is clickable and keyboard-focusable. */}
|
||||
<button
|
||||
@@ -115,47 +111,41 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
|
||||
onClick={() => onOpen(article)}
|
||||
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}
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
{article.ai_summary ? (
|
||||
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">
|
||||
<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}
|
||||
{(article.ai_summary || article.excerpt) && (
|
||||
<p className="text-paper-muted text-[13px] leading-relaxed line-clamp-3">{article.ai_summary ? summaryText(article.ai_summary) : article.excerpt}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-2 border-t border-paper-muted/10">
|
||||
<p className="text-xs text-paper-muted">
|
||||
<time dateTime={date}>{formatRelative(date)}</time>
|
||||
<span aria-hidden="true"> · </span>
|
||||
{readingTimeLabel(article.reading_time)}
|
||||
</p>
|
||||
<div className="mt-auto flex items-center gap-2 pt-1 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>
|
||||
<span className="flex-1" />
|
||||
<div className="relative z-10 flex items-center -mr-2">
|
||||
<button
|
||||
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)'}
|
||||
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">
|
||||
<circle cx="12" cy="12" r="5" strokeWidth={1.8} />
|
||||
</svg>
|
||||
<Icon name="check" size={18} />
|
||||
</button>
|
||||
<button
|
||||
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)'}
|
||||
aria-label="Favori"
|
||||
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">
|
||||
<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>
|
||||
<Icon name="star" size={18} filled={article.is_favorite} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
@@ -16,20 +16,13 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
|
||||
<div className="min-h-screen flex">
|
||||
{/* Brand panel */}
|
||||
<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
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
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
|
||||
@@ -38,7 +31,7 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
|
||||
transition={{ duration: 0.7, delay: 0.1 }}
|
||||
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.
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
{/* 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
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<span className="text-nature text-4xl font-serif italic">FlowReader</span>
|
||||
<div className="lg:hidden mb-6">
|
||||
<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 className="surface-card p-8 sm:p-10">
|
||||
<div>
|
||||
<header className="mb-8">
|
||||
<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>
|
||||
</header>
|
||||
{children}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Article } from '../api/articles';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface ShareButtonProps {
|
||||
article: Pick<Article, 'title' | 'url' | 'excerpt'>;
|
||||
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 [copied, setCopied] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -71,9 +75,7 @@ export function ShareButton({ article, className = 'icon-btn' }: ShareButtonProp
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="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>
|
||||
<Icon name="share" size={iconSize} />
|
||||
</button>
|
||||
{copied && (
|
||||
<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
|
||||
ref={menuRef}
|
||||
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) => (
|
||||
<button
|
||||
key={it.label}
|
||||
role="menuitem"
|
||||
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}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-paper-muted/15 my-1" />
|
||||
<div className="border-t border-line my-1" />
|
||||
<button
|
||||
role="menuitem"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -11,6 +11,7 @@ const GROUPS: { title: string; keys: [string, string][] }[] = [
|
||||
['v', 'Ouvrir l’original'],
|
||||
['u', 'Basculer Non lus / Tous'],
|
||||
['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"
|
||||
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">
|
||||
{GROUPS.map((g) => (
|
||||
<section key={g.title}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Article } from '../../api/articles';
|
||||
import { formatRelative, readingTimeLabel } from '../../lib/format';
|
||||
import { summaryText } from '../../lib/summary';
|
||||
|
||||
interface FocusCardProps {
|
||||
article: Article;
|
||||
@@ -10,9 +11,9 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
|
||||
const date = article.published_at || article.created_at;
|
||||
|
||||
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 */}
|
||||
<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 ? (
|
||||
<img
|
||||
src={article.image_url}
|
||||
@@ -24,8 +25,8 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-nature/12 p-8 flex items-end">
|
||||
<span className="text-2xl font-serif italic text-nature leading-tight line-clamp-3">{article.feed_title}</span>
|
||||
<div className="w-full h-full bg-nature-soft p-8 flex items-end">
|
||||
<span className="text-2xl font-serif font-semibold text-nature leading-tight line-clamp-3">{article.feed_title}</span>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</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}
|
||||
</h2>
|
||||
|
||||
{(article.ai_summary || article.excerpt) && (
|
||||
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">
|
||||
{article.ai_summary || article.excerpt}
|
||||
<p className="text-paper-muted text-base leading-normal line-clamp-4 font-reading">
|
||||
{article.ai_summary ? summaryText(article.ai_summary) : article.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isTop && (
|
||||
<p className="mt-auto pt-3 text-center text-xs text-paper-muted hidden sm:block">
|
||||
← Garder · Lu → · Entrée pour lire
|
||||
<p className="mt-auto pt-3 text-center text-xs text-paper-muted">
|
||||
← Plus tard · Lu → · Entrée pour lire
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Article } from '../../api/articles';
|
||||
import { useArticleActions } from '../../hooks/useArticleActions';
|
||||
import { FocusCard } from './FocusCard';
|
||||
import { Reader } from '../reader/Reader';
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
interface FocusCardStackProps {
|
||||
articles: Article[];
|
||||
@@ -59,7 +60,7 @@ function SwipeableCard({ article, index, isTop, onAction }: SwipeableCardProps)
|
||||
</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">
|
||||
<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>
|
||||
</motion.div>
|
||||
</>
|
||||
@@ -141,15 +142,24 @@ export function FocusCardStack({ articles, onEmpty, onExit }: FocusCardStackProp
|
||||
if (!top) return null;
|
||||
|
||||
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>
|
||||
|
||||
<span className="inline-block bg-nature-dark/80 px-4 py-2 rounded-full text-white text-xs font-semibold">
|
||||
{articles.length - currentIndex} restant{articles.length - currentIndex > 1 ? 's' : ''}
|
||||
</span>
|
||||
{/* Progress through the deck */}
|
||||
<div className="w-full max-w-md flex flex-col items-center gap-2">
|
||||
<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 */}
|
||||
<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}>
|
||||
{visible.map((article, i) => (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Controls (every gesture has a button equivalent) */}
|
||||
<div className="flex justify-center items-center gap-3 sm:gap-5 pb-4">
|
||||
{/* Controls (every gesture has a labelled button equivalent) */}
|
||||
<div className="flex justify-center items-start gap-5 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
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 (←)"
|
||||
aria-label="Garder pour plus tard"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
<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">
|
||||
<Icon name="clock" size={26} strokeWidth={2} />
|
||||
</span>
|
||||
Plus tard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={undo}
|
||||
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)"
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
<span className="w-12 h-12 rounded-full border border-line bg-carbon-light text-paper-white flex items-center justify-center">
|
||||
<Icon name="undo" size={20} strokeWidth={2} />
|
||||
</span>
|
||||
Annuler
|
||||
</button>
|
||||
|
||||
<button onClick={() => setReading(true)} className="btn-secondary bg-carbon-light" title="Lire l’article (Entrée)">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
<button
|
||||
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 (→)"
|
||||
aria-label="Marquer comme lu"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="w-[60px] h-[60px] rounded-full bg-nature text-on-nature flex items-center justify-center hover:bg-nature-light transition-colors">
|
||||
<Icon name="check" size={28} strokeWidth={2.4} />
|
||||
</span>
|
||||
Lu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -204,6 +218,7 @@ export function FocusCardStack({ articles, onEmpty, onExit }: FocusCardStackProp
|
||||
article={top}
|
||||
next={articles[currentIndex + 1]}
|
||||
hasPrev={false}
|
||||
backLabel="Mode Focus"
|
||||
onClose={() => setReading(false)}
|
||||
onNext={() => {
|
||||
act('read');
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { Icon } from '../Icon';
|
||||
|
||||
interface FocusEmptyStateProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function FocusEmptyState({ onBack }: FocusEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center space-y-8 px-6 animate-rise">
|
||||
<div className="relative">
|
||||
<div className="w-28 h-28 rounded-full bg-nature/10 flex items-center justify-center animate-bounce-slow">
|
||||
<span className="text-5xl" aria-hidden="true">🌿</span>
|
||||
</div>
|
||||
<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">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-serif italic text-paper-white">C'est tout pour le moment !</h1>
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-5 px-6 animate-rise">
|
||||
<span aria-hidden="true" className="w-20 h-20 rounded-full bg-nature-soft text-nature flex items-center justify-center">
|
||||
<Icon name="check" size={36} strokeWidth={2.2} />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-3xl font-semibold text-paper-white">Tout est trié</h1>
|
||||
<p className="text-paper-muted">Vous êtes à jour sur vos lectures.</p>
|
||||
</div>
|
||||
|
||||
<button onClick={onBack} className="btn-primary py-3 px-8" autoFocus>Retour au tableau de bord</button>
|
||||
<button onClick={onBack} className="btn-primary !px-6" autoFocus>Retour à la liste</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,30 @@ import { createPortal } from 'react-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import { articlesApi, type Article } from '../../api/articles';
|
||||
import { patchArticle, useArticleActions } from '../../hooks/useArticleActions';
|
||||
import { useArticleActions } from '../../hooks/useArticleActions';
|
||||
import { usePrefs } from '../../stores/prefsStore';
|
||||
import { formatLong, readingTimeLabel } from '../../lib/format';
|
||||
import { prepareArticleHtml } from '../../lib/html';
|
||||
import { ShareButton } from '../ShareButton';
|
||||
import { FeedAvatar } from '../FeedAvatar';
|
||||
import { Icon } from '../Icon';
|
||||
import { ReaderSettings } from './ReaderSettings';
|
||||
import { AiSummary } from './AiSummary';
|
||||
|
||||
interface ReaderProps {
|
||||
/** The list item being read (title, image… shown instantly while the body loads). */
|
||||
article: Article;
|
||||
next?: Article;
|
||||
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;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
@@ -32,18 +44,22 @@ function isTypingTarget(t: EventTarget | null) {
|
||||
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
||||
}
|
||||
|
||||
const Chevron = ({ dir }: { dir: 'left' | 'right' }) => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={dir === 'left' ? 'M15 19l-7-7 7-7' : 'M9 5l7 7-7 7'} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Article opened as a large card above the (dimmed) grid.
|
||||
* Close: ✕, Escape or a click beside the card. Previous / next: side arrows
|
||||
* on desktop, bottom bar on mobile, j/k or ←/→ on the keyboard.
|
||||
* The article reader. Close: ✕, Escape, back (or a click beside the card in
|
||||
* overlay mode). Previous / next: toolbar arrows on desktop, "Suivant" in the
|
||||
* 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 { toggleRead, toggleFavorite } = useArticleActions();
|
||||
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 font = usePrefs((s) => s.font);
|
||||
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 titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [summarizing, setSummarizing] = useState(false);
|
||||
const [summaryError, setSummaryError] = useState('');
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
|
||||
const { data: detail, isError, refetch } = useQuery({
|
||||
queryKey: ['article', listArticle.id],
|
||||
@@ -82,25 +98,25 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
return () => window.clearTimeout(t);
|
||||
}, [next, qc]);
|
||||
|
||||
// Modal behaviour: the app behind is inert and doesn't scroll; focus
|
||||
// moves to the title and returns to the card that opened the article.
|
||||
// Focus moves to the title. In overlay mode the app behind is inert and
|
||||
// doesn't scroll, and focus returns to the row that opened the article.
|
||||
useLayoutEffect(() => {
|
||||
const root = document.getElementById('root');
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const root = overlay ? document.getElementById('root') : null;
|
||||
root?.setAttribute('inert', '');
|
||||
document.body.style.overflow = 'hidden';
|
||||
overlayRef.current?.scrollTo({ top: 0 });
|
||||
if (overlay) document.body.style.overflow = 'hidden';
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
root?.removeAttribute('inert');
|
||||
document.body.style.overflow = '';
|
||||
previous?.focus?.({ preventScroll: true });
|
||||
if (overlay) document.body.style.overflow = '';
|
||||
if (overlay) previous?.focus?.({ preventScroll: true });
|
||||
};
|
||||
}, []);
|
||||
}, [overlay]);
|
||||
|
||||
// Reading progress without React re-renders.
|
||||
useEffect(() => {
|
||||
const el = overlayRef.current;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
@@ -127,7 +143,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return;
|
||||
const el = overlayRef.current;
|
||||
const el = scrollRef.current;
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
@@ -174,7 +190,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, onNext, onPrev, hasPrev, toggleFavorite, toggleRead, listArticle, openOriginal, fontSize, setFontSize]);
|
||||
|
||||
// Optional touch shortcut on phones; the buttons below do the same.
|
||||
// Optional touch shortcut on phones; the buttons do the same.
|
||||
const swipe = useSwipeable({
|
||||
onSwipedLeft: (ev) => {
|
||||
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,
|
||||
});
|
||||
|
||||
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 = {
|
||||
'--reader-size': `${fontSize}px`,
|
||||
'--reader-leading': String(lineHeight),
|
||||
@@ -209,162 +212,160 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
} as React.CSSProperties;
|
||||
|
||||
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 = (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-nature-dark/45 backdrop-blur-sm animate-fade-in"
|
||||
ref={scrollRef}
|
||||
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) => {
|
||||
// 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
|
||||
{...swipe}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
role={overlay ? 'dialog' : undefined}
|
||||
aria-modal={overlay ? true : undefined}
|
||||
aria-labelledby="reader-title"
|
||||
className="relative mx-auto w-full max-w-3xl min-h-full md:min-h-0 md:my-10 bg-carbon-light md:rounded-3xl overflow-clip animate-rise"
|
||||
style={{ ...style, boxShadow: 'var(--shadow-float)' }}
|
||||
className={
|
||||
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 */}
|
||||
<div className="sticky top-0 z-20 bg-carbon-light/95 backdrop-blur border-b border-paper-muted/10">
|
||||
<div className="flex items-center gap-1 pl-5 pr-2 sm:pl-7 sm:pr-3 h-16">
|
||||
<div className="flex-1 min-w-0 text-sm">
|
||||
<p className="font-semibold text-nature truncate">{listArticle.feed_title || 'Article'}</p>
|
||||
<p className="text-paper-muted truncate">
|
||||
<time dateTime={date}>{formatLong(date)}</time> · {readingTimeLabel(listArticle.reading_time)}<span className="hidden sm:inline"> de lecture</span>
|
||||
</p>
|
||||
</div>
|
||||
{/* Toolbar */}
|
||||
<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-0.5 h-[52px] md:h-[60px] pl-1 pr-1.5 md:px-3">
|
||||
<button
|
||||
onClick={() => toggleFavorite(listArticle)}
|
||||
className={`w-11 h-11 inline-flex items-center justify-center rounded-full transition-colors ${
|
||||
article.is_favorite ? 'text-earth' : 'text-paper-muted hover:text-earth'
|
||||
}`}
|
||||
aria-pressed={article.is_favorite}
|
||||
aria-label="Favori"
|
||||
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
|
||||
onClick={onClose}
|
||||
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%]"
|
||||
>
|
||||
<svg className="w-5 h-5" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<Icon name="chevronLeft" size={24} strokeWidth={2} />
|
||||
<span className="truncate">{backLabel}</span>
|
||||
</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">
|
||||
<button
|
||||
onClick={() => setSettingsOpen((o) => !o)}
|
||||
className="w-11 h-11 inline-flex items-center justify-center rounded-full text-paper-muted hover:text-nature font-serif text-base transition-colors"
|
||||
className="tool-btn !text-paper-white font-serif !text-lg font-semibold"
|
||||
aria-expanded={settingsOpen}
|
||||
aria-label="Affichage du texte"
|
||||
title="Affichage du texte"
|
||||
aria-label="Réglages de lecture"
|
||||
title="Taille, police, thème"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
{settingsOpen && <ReaderSettings onClose={() => setSettingsOpen(false)} />}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-1 w-11 h-11 inline-flex items-center justify-center rounded-full bg-carbon-dark text-paper-white hover:bg-nature hover:text-on-nature transition-colors"
|
||||
aria-label="Fermer"
|
||||
title="Fermer (Échap)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="hidden md:flex items-center gap-0.5">
|
||||
{favButton('tool-btn !text-paper-white', 19)}
|
||||
<ShareButton article={article} className="tool-btn !text-paper-white" />
|
||||
{listArticle.url && (
|
||||
<a
|
||||
href={listArticle.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Ouvrir l’original (v)"
|
||||
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 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>
|
||||
</div>
|
||||
|
||||
{listArticle.image_url && (
|
||||
<img
|
||||
src={listArticle.image_url}
|
||||
alt=""
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full max-h-[42vh] object-cover bg-carbon-dark"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="px-5 sm:px-10 pb-10 mx-auto" style={{ maxWidth: `calc(${MEASURES[width]} + 5rem)` }}>
|
||||
<header className="pt-8 pb-6">
|
||||
<div className="w-full px-5 md:px-8 pt-6 md:pt-10 pb-10 mx-auto" style={{ maxWidth: `calc(${MEASURES[width]} + 4rem)` }}>
|
||||
<header>
|
||||
<p className="flex items-center gap-2 flex-wrap text-[13px] text-paper-muted">
|
||||
<FeedAvatar id={listArticle.feed_id} title={listArticle.feed_title} />
|
||||
<span className="font-semibold text-nature">{listArticle.feed_title || 'Article'}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<time dateTime={date}>{formatLong(date)}</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{readingTimeLabel(listArticle.reading_time)} de lecture</span>
|
||||
</p>
|
||||
<h1
|
||||
id="reader-title"
|
||||
ref={titleRef}
|
||||
tabIndex={-1}
|
||||
className="font-serif text-paper-white leading-[1.15] tracking-tight text-balance outline-none"
|
||||
style={{ fontSize: 'clamp(1.75rem, 1.3rem + 1.8vw, 2.6rem)' }}
|
||||
className="mt-3.5 font-semibold text-paper-white leading-[1.13] tracking-[-0.015em] text-balance outline-none"
|
||||
style={{ fontSize: 'clamp(1.8rem, 1.4rem + 1.4vw, 2.5rem)' }}
|
||||
>
|
||||
{listArticle.title}
|
||||
</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 ? (
|
||||
<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>
|
||||
)}
|
||||
<AiSummary articleId={listArticle.id} summary={article.ai_summary} />
|
||||
</header>
|
||||
|
||||
{detail ? (
|
||||
html ? (
|
||||
<div className="magazine-content" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<p className="text-paper-muted italic">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>
|
||||
{showImage && (
|
||||
<img
|
||||
src={listArticle.image_url}
|
||||
alt=""
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
className="mt-7 w-full max-h-[48vh] object-cover rounded-2xl bg-carbon-dark"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 && (
|
||||
<a
|
||||
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"
|
||||
>
|
||||
Lire sur le site d’origine
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
<Icon name="external" size={16} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom navigation: always visible on mobile, "up next" on every size */}
|
||||
<nav className="sticky bottom-0 z-20 border-t border-paper-muted/10 bg-carbon-light/95 backdrop-blur" aria-label="Navigation entre articles">
|
||||
<div className="flex items-stretch">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!hasPrev || !onPrev}
|
||||
className="flex items-center gap-2 px-4 sm:px-6 min-h-14 text-sm font-medium text-paper-muted hover:text-nature disabled:opacity-35 disabled:pointer-events-none"
|
||||
>
|
||||
<Chevron dir="left" />
|
||||
<span className="hidden sm:inline">Précédent</span>
|
||||
</button>
|
||||
{/* Up next (desktop / tablet) */}
|
||||
<div className="hidden md:block mt-12 pt-5 border-t border-line">
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
className="flex-1 min-w-0 flex items-center justify-end gap-3 px-4 sm:px-6 min-h-14 text-right hover:text-nature disabled:opacity-35 disabled:pointer-events-none group"
|
||||
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="block text-xs text-paper-muted">Article suivant</span>
|
||||
<span className="block text-sm font-semibold text-paper-white group-hover:text-nature truncate">
|
||||
{next ? next.title : onNext ? 'Charger la suite' : 'Fin de la liste'}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-semibold text-paper-muted">Article suivant · j</span>
|
||||
<span className="block mt-1 font-serif text-lg font-semibold leading-snug text-paper-white truncate">
|
||||
{next ? next.title : onNext ? 'Charger la suite' : 'Vous êtes à jour — plus rien à lire ici.'}
|
||||
</span>
|
||||
</span>
|
||||
<Chevron dir="right" />
|
||||
<Icon name="arrowRight" size={20} strokeWidth={2} className="text-nature shrink-0" />
|
||||
</button>
|
||||
</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>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(body, document.body);
|
||||
return overlay ? createPortal(body, document.body) : body;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
usePrefs,
|
||||
FONT_SIZE_MAX,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
type ReaderWidth,
|
||||
type ThemePref,
|
||||
} from '../../stores/prefsStore';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
|
||||
interface ReaderSettingsProps {
|
||||
onClose: () => void;
|
||||
@@ -14,9 +16,9 @@ interface ReaderSettingsProps {
|
||||
|
||||
const THEMES: { value: ThemePref; label: string; swatch: string }[] = [
|
||||
{ value: 'light', label: 'Clair', swatch: '#FFFFFF' },
|
||||
{ value: 'sepia', label: 'Sépia', swatch: '#F4ECD8' },
|
||||
{ value: 'dark', label: 'Sombre', swatch: '#161D17' },
|
||||
{ value: 'system', label: 'Auto', swatch: 'linear-gradient(135deg,#fff 50%,#161D17 50%)' },
|
||||
{ value: 'sepia', label: 'Sépia', swatch: '#F1E7D0' },
|
||||
{ value: 'dark', label: 'Sombre', swatch: '#161B18' },
|
||||
{ value: 'system', label: 'Auto', swatch: 'linear-gradient(135deg,#fff 50%,#161B18 50%)' },
|
||||
];
|
||||
|
||||
const FONTS: { value: ReaderFont; label: string; family: string }[] = [
|
||||
@@ -37,10 +39,14 @@ const LEADINGS = [
|
||||
{ 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) {
|
||||
const prefs = usePrefs();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
panelRef.current?.focus();
|
||||
@@ -54,7 +60,9 @@ export function ReaderSettings({ onClose }: ReaderSettingsProps) {
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [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
|
||||
@@ -62,8 +70,21 @@ export function ReaderSettings({ onClose }: ReaderSettingsProps) {
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label="Réglages de lecture"
|
||||
className="absolute right-0 top-14 z-[61] w-[min(22rem,calc(100vw-2rem))] surface-card p-5 space-y-5 animate-fade-in text-paper-white"
|
||||
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>
|
||||
<legend className="eyebrow text-paper-muted mb-2">Thème</legend>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
@@ -158,4 +179,5 @@ export function ReaderSettings({ onClose }: ReaderSettingsProps) {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return isMobile ? createPortal(panel, document.body) : panel;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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) {
|
||||
const media = window.matchMedia(query);
|
||||
|
||||
@@ -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
@@ -1,42 +1,45 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
FlowReader — Design System "Éditorial Botanique"
|
||||
Light: Organic Warmth · Sepia: Paper · Dark: Deep Forest
|
||||
FlowReader — Design System "Calme, lisible, rapide"
|
||||
Light: Paper · Sepia: Long-form · Dark: Deep Forest (no pure black)
|
||||
Fonts are self-hosted (see main.tsx) — no third-party requests.
|
||||
============================================================ */
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* Surfaces — warm paper */
|
||||
--color-carbon: #F7F4EC; /* app background */
|
||||
--color-carbon-light: #FFFFFF; /* raised surface / cards */
|
||||
--color-carbon-dark: #E9E4D8; /* borders / sunken */
|
||||
/* Surfaces — paper */
|
||||
--color-carbon: #F6F4EE; /* app background */
|
||||
--color-carbon-light: #FFFFFF; /* raised surface / panes / cards */
|
||||
--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 */
|
||||
--color-nature: #15643A;
|
||||
--color-nature-light: #1E8A4F;
|
||||
/* Brand — forest green: actions and the "unread" state */
|
||||
--color-nature: #1B6B43;
|
||||
--color-nature-light: #23804F;
|
||||
--color-nature-dark: #0A2E1B;
|
||||
--color-nature-soft: #E4EFE8; /* AI summary, chips */
|
||||
--color-on-nature: #FFFFFF; /* text on a nature-filled surface */
|
||||
|
||||
/* Accent — terracotta (used sparingly) */
|
||||
--color-earth: #A9502F;
|
||||
/* Accent — terracotta, reserved for favorites */
|
||||
--color-earth: #B4532A;
|
||||
--color-earth-light: #C9714F;
|
||||
--color-on-earth: #FFFFFF;
|
||||
|
||||
/* Ink */
|
||||
--color-paper-white: #1B1714; /* primary text */
|
||||
--color-paper-muted: #635C53; /* secondary text (≥ 5:1 on paper) */
|
||||
--color-paper-white: #1A1C19; /* primary text */
|
||||
--color-paper-muted: #5F625B; /* secondary text (≥ 5.6:1 on paper) */
|
||||
|
||||
/* Semantic */
|
||||
--color-ring: #1E8A4F;
|
||||
--color-danger: #B3390A;
|
||||
--color-ring: #1B6B43;
|
||||
--color-danger: #A8401A;
|
||||
|
||||
/* Typography */
|
||||
--font-serif: "Playfair Display Variable", "Playfair Display", Georgia, serif;
|
||||
/* Typography: Newsreader (made for on-screen news reading) + Inter for UI */
|
||||
--font-serif: "Newsreader Variable", "Newsreader", Georgia, 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;
|
||||
|
||||
/* Radii scale */
|
||||
@@ -47,20 +50,23 @@
|
||||
--radius-xl: 1.75rem;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-soft: 0 2px 8px -2px rgba(27, 23, 20, 0.08), 0 4px 24px -6px rgba(27, 23, 20, 0.06);
|
||||
--shadow-lift: 0 12px 32px -12px rgba(27, 23, 20, 0.18);
|
||||
--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(26, 28, 25, 0.18);
|
||||
--shadow-float: 0 24px 60px -20px rgba(10, 46, 27, 0.28);
|
||||
}
|
||||
|
||||
/* ---------- Sepia theme: long-form paper ---------- */
|
||||
.sepia {
|
||||
--color-carbon: #F4ECD8;
|
||||
--color-carbon-light: #FBF5E6;
|
||||
--color-carbon-dark: #E6DCC3;
|
||||
--color-carbon: #F1E7D0;
|
||||
--color-carbon-light: #FBF4E4;
|
||||
--color-carbon-dark: #E8DCC2;
|
||||
--color-line: #DFD1B5;
|
||||
--color-select: #ECE6CF;
|
||||
|
||||
--color-nature: #2D5F34;
|
||||
--color-nature-light: #3B7A44;
|
||||
--color-nature-dark: #1C3320;
|
||||
--color-nature-soft: #E6E6CC;
|
||||
|
||||
--color-earth: #9A4A2A;
|
||||
|
||||
@@ -72,24 +78,27 @@
|
||||
|
||||
/* ---------- Dark theme: Deep Forest (no pure black / pure white) ---------- */
|
||||
.dark {
|
||||
--color-carbon: #0E130F;
|
||||
--color-carbon-light: #161D17;
|
||||
--color-carbon-dark: #090D0A;
|
||||
--color-carbon: #0F1311;
|
||||
--color-carbon-light: #161B18;
|
||||
--color-carbon-dark: #0B0E0C;
|
||||
--color-line: #252C28;
|
||||
--color-select: #18291F;
|
||||
|
||||
--color-nature: #34D27B;
|
||||
--color-nature-light: #5DE89A;
|
||||
--color-nature: #45D18A;
|
||||
--color-nature-light: #6BE0A5;
|
||||
--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-light: #E59A7E;
|
||||
--color-earth: #E8946F;
|
||||
--color-earth-light: #EDA585;
|
||||
--color-on-earth: #2A0F05;
|
||||
|
||||
--color-paper-white: #E9E5DA;
|
||||
--color-paper-muted: #A39E92;
|
||||
--color-paper-white: #E7E9E4;
|
||||
--color-paper-muted: #9BA39C;
|
||||
|
||||
--color-ring: #34D27B;
|
||||
--color-danger: #F87045;
|
||||
--color-ring: #45D18A;
|
||||
--color-danger: #F08A65;
|
||||
|
||||
--shadow-soft: 0 2px 10px -2px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lift: 0 16px 40px -16px rgba(0, 0, 0, 0.6);
|
||||
@@ -132,6 +141,20 @@
|
||||
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
|
||||
============================================================ */
|
||||
@@ -139,7 +162,7 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-color: color-mix(in oklab, var(--color-paper-muted) 18%, transparent);
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -150,9 +173,6 @@
|
||||
@apply bg-carbon text-paper-white font-sans antialiased;
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -210,7 +230,7 @@
|
||||
@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
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -235,12 +255,29 @@
|
||||
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 {
|
||||
@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 {
|
||||
@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;
|
||||
}
|
||||
.segmented > button[aria-pressed="true"],
|
||||
@@ -251,7 +288,7 @@
|
||||
/* Inputs */
|
||||
.input-field {
|
||||
@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
|
||||
focus:border-nature/50 focus:ring-2 focus:ring-nature/25 outline-none;
|
||||
}
|
||||
@@ -260,12 +297,12 @@
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full
|
||||
text-xs font-semibold
|
||||
bg-nature/10 text-nature;
|
||||
bg-nature-soft text-nature;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@@ -405,6 +442,7 @@
|
||||
.animate-rise { animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.animate-bounce-slow { animation: bounce-slow 3s infinite; }
|
||||
.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 {
|
||||
from { opacity: 0; }
|
||||
@@ -418,6 +456,10 @@
|
||||
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); }
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
|
||||
+110
-13
@@ -1,34 +1,114 @@
|
||||
import { Suspense, useState } from 'react';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
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 { 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 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() {
|
||||
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
|
||||
const [isFocusMode, setIsFocusMode] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
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).
|
||||
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 (
|
||||
<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">
|
||||
Aller au contenu
|
||||
</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">
|
||||
{/* Kept mounted in focus mode so the list keeps its scroll position. */}
|
||||
<DashboardPage selectedFeedId={selectedFeedId} hidden={isFocusMode} />
|
||||
<div id="main-content" className="flex-1 min-w-0 flex flex-col">
|
||||
{/* Kept mounted behind focus mode and the feeds tab so the list keeps its scroll position. */}
|
||||
<DashboardPage hidden={focusOpen || showFeeds} />
|
||||
{showFeeds && (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<FeedsPage />
|
||||
</Suspense>
|
||||
)}
|
||||
{!focusOpen && <MobileTabBar />}
|
||||
</div>
|
||||
|
||||
{isFocusMode && (
|
||||
{focusOpen && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<FocusPage onExit={() => setIsFocusMode(false)} />
|
||||
<FocusPage onExit={() => setFocusOpen(false)} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -4,10 +4,8 @@ import { registerSW } from 'virtual:pwa-register'
|
||||
// Self-hosted variable fonts: no third-party request, cached offline by the SW.
|
||||
// Browsers only download the unicode-range subsets a page actually uses.
|
||||
import '@fontsource-variable/inter/wght.css'
|
||||
import '@fontsource-variable/lora/wght.css'
|
||||
import '@fontsource-variable/lora/wght-italic.css'
|
||||
import '@fontsource-variable/playfair-display/wght.css'
|
||||
import '@fontsource-variable/playfair-display/wght-italic.css'
|
||||
import '@fontsource-variable/newsreader/wght.css'
|
||||
import '@fontsource-variable/newsreader/wght-italic.css'
|
||||
import '@fontsource/atkinson-hyperlegible/latin-400.css'
|
||||
import '@fontsource/atkinson-hyperlegible/latin-700.css'
|
||||
import './index.css'
|
||||
|
||||
+323
-161
@@ -1,21 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 { feedsApi } from '../api/feeds';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { ArticleRow } from '../components/ArticleRow';
|
||||
import { Reader } from '../components/reader/Reader';
|
||||
import { ShortcutsHelp } from '../components/ShortcutsHelp';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions';
|
||||
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 { useUI } from '../stores/uiStore';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
interface DashboardPageProps {
|
||||
selectedFeedId: string | null;
|
||||
/** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */
|
||||
/** Hidden (kept mounted) while focus mode or the feeds tab is shown, to preserve scroll. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,23 +27,36 @@ function isTypingTarget(t: EventTarget | null) {
|
||||
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 [params, setParams] = useSearchParams();
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const listRef = useRef<HTMLElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
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 setUnreadOnly = usePrefs((s) => s.setUnreadOnly);
|
||||
const view = usePrefs((s) => s.view);
|
||||
const setView = usePrefs((s) => s.setView);
|
||||
const newCount = useLive((s) => s.newCount);
|
||||
const clearNew = useLive((s) => s.clearNew);
|
||||
const { setRead, toggleRead, toggleFavorite } = useArticleActions();
|
||||
|
||||
const favorites = selectedFeedId === 'favorites';
|
||||
const feedId = favorites ? null : selectedFeedId;
|
||||
const favorites = scope === 'favorites';
|
||||
const feedId = favorites ? null : scope;
|
||||
const unreadOnly = unreadPref && !favorites;
|
||||
const paneMode = !isMobile && view === 'list';
|
||||
|
||||
const listKey = useMemo(
|
||||
() => ['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 { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
|
||||
const { data: feeds } = useFeeds();
|
||||
const unreadTotal = useMemo(() => {
|
||||
if (!feeds) return 0;
|
||||
if (feedId) return feeds.find((f) => f.id === feedId)?.unread_count ?? 0;
|
||||
return feeds.reduce((n, f) => n + (f.unread_count ?? 0), 0);
|
||||
}, [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 ----------
|
||||
const openId = params.get('a');
|
||||
@@ -122,6 +157,12 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
if (openIndex > 0) openReader(articles[openIndex - 1], true);
|
||||
}, [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) ----------
|
||||
useEffect(() => {
|
||||
const target = loadMoreRef.current;
|
||||
@@ -130,21 +171,21 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
(entries) => {
|
||||
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);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, fetchNextPage, isFetchingNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = mainRef.current;
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => setShowScrollTop(el.scrollTop > 900);
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
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() });
|
||||
|
||||
@@ -155,6 +196,11 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
qc.invalidateQueries({ queryKey: ['feeds'] });
|
||||
},
|
||||
});
|
||||
const markAll = () => {
|
||||
if (window.confirm(`Marquer ${unreadTotal} article${unreadTotal > 1 ? 's' : ''} comme lu${unreadTotal > 1 ? 's' : ''} ?`)) {
|
||||
markAllMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const showNew = () => {
|
||||
clearNew();
|
||||
@@ -162,10 +208,10 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
scrollTop();
|
||||
};
|
||||
|
||||
// ---------- Keyboard navigation on the list ----------
|
||||
// ---------- Keyboard navigation on the list (the reader handles its own) ----------
|
||||
useEffect(() => {
|
||||
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 el = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>('[data-index]');
|
||||
return el ? Number(el.dataset.index) : -1;
|
||||
@@ -210,7 +256,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
refreshMutation.mutate();
|
||||
break;
|
||||
case '?':
|
||||
setShowHelp(true);
|
||||
openDialog('help');
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
@@ -219,169 +265,285 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
};
|
||||
window.addEventListener('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 (
|
||||
<main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}>
|
||||
<div className="max-w-[2400px] mx-auto px-4 sm:px-6 md:px-12 py-6 md:py-8">
|
||||
{/* The page title is the feed selector in the top bar. */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<p className="text-paper-muted text-sm" aria-live="polite">
|
||||
{favorites
|
||||
? `${articles.length}${hasNextPage ? '+' : ''} favori${articles.length > 1 ? 's' : ''}`
|
||||
: unreadTotal === 0
|
||||
? 'Tout est lu'
|
||||
: `${unreadTotal} article${unreadTotal > 1 ? 's' : ''} non lu${unreadTotal > 1 ? 's' : ''}`}
|
||||
</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>
|
||||
<div className={`flex-1 min-w-0 min-h-0 flex ${hidden ? 'hidden' : ''}`}>
|
||||
<section
|
||||
ref={listRef}
|
||||
aria-label="Articles"
|
||||
aria-busy={isFetching}
|
||||
className={`relative min-h-0 overflow-y-auto overscroll-contain bg-carbon md:bg-carbon-light ${
|
||||
paneMode ? 'w-[min(400px,42vw)] shrink-0 border-r border-line' : 'flex-1 min-w-0'
|
||||
}`}
|
||||
>
|
||||
{header}
|
||||
|
||||
{/* New articles are announced, never inserted under the reader's eyes */}
|
||||
{newCount > 0 && (
|
||||
<div className="sticky top-3 z-30 flex justify-center mb-6 pointer-events-none">
|
||||
<button onClick={showNew} className="btn-primary pointer-events-auto shadow-lg normal-case tracking-normal text-sm">
|
||||
Afficher {newCount} nouvel{newCount > 1 ? 's' : ''} article{newCount > 1 ? 's' : ''}
|
||||
<div className="sticky top-[132px] md:top-[124px] z-10 flex justify-center pt-3 pointer-events-none">
|
||||
<button onClick={showNew} className="btn-primary !min-h-9 !px-4 pointer-events-auto shadow-[var(--shadow-lift)]">
|
||||
<Icon name="arrowUp" size={14} strokeWidth={2.2} />
|
||||
{newCount} nouvel{newCount > 1 ? 's' : ''} article{newCount > 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{isPending ? (
|
||||
<div className="article-grid" aria-label="Chargement des articles" role="status">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-carbon-light border border-paper-muted/10 overflow-hidden">
|
||||
<div className="aspect-[16/9] bg-carbon-dark animate-pulse" />
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="h-5 w-4/5 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={view === 'magazine' ? 'max-w-[2400px] mx-auto px-4 sm:px-6 md:px-8 py-6' : 'bg-carbon-light'}>
|
||||
{isPending ? (
|
||||
<div role="status" aria-label="Chargement des articles">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="flex gap-3 pl-8 pr-4 py-4 border-b border-line">
|
||||
<div className="flex-1 space-y-2.5">
|
||||
<div className="h-3 w-1/3 rounded bg-carbon-dark animate-pulse" />
|
||||
<div className="h-4 w-11/12 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>
|
||||
) : 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 ref={loadMoreRef} className="mt-14 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 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px w-16 bg-nature/25" />
|
||||
<p className="text-sm text-paper-muted">Fin de la liste</p>
|
||||
<span className="h-px w-16 bg-nature/25" />
|
||||
) : isError && articles.length === 0 ? (
|
||||
<div role="alert" className="flex flex-col items-center justify-center py-24 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 ? (
|
||||
<>
|
||||
{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 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}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back to top */}
|
||||
<button
|
||||
onClick={scrollTop}
|
||||
className={`fixed bottom-6 right-6 z-40 icon-btn !w-12 !h-12 bg-carbon-light shadow-lg 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}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Back to top */}
|
||||
<div className="sticky bottom-5 z-20 flex justify-end pr-5 pointer-events-none">
|
||||
<button
|
||||
onClick={scrollTop}
|
||||
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}
|
||||
>
|
||||
<Icon name="arrowUp" size={20} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{openArticle && !hidden && (
|
||||
<Reader
|
||||
key={openArticle.id}
|
||||
article={openArticle}
|
||||
next={openIndex >= 0 ? articles[openIndex + 1] : undefined}
|
||||
hasPrev={openIndex > 0}
|
||||
onClose={closeReader}
|
||||
onNext={openIndex >= 0 && (openIndex + 1 < articles.length || hasNextPage) ? goNext : undefined}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
{paneMode && (
|
||||
<div className="flex-1 min-w-0 min-h-0 bg-carbon-light">
|
||||
{reader || (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<span aria-hidden="true" className="w-16 h-16 rounded-full bg-carbon-dark text-paper-muted flex items-center justify-center">
|
||||
<Icon name="book" size={28} />
|
||||
</span>
|
||||
<p className="font-serif text-xl font-semibold text-paper-white">Choisissez un article</p>
|
||||
<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)} />}
|
||||
</main>
|
||||
/** "⋯" menu of the list header on phones. */
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { MotionConfig } from 'framer-motion';
|
||||
import { articlesApi } from '../api/articles';
|
||||
import { FocusCardStack } from '../components/focus/FocusCardStack';
|
||||
import { FocusEmptyState } from '../components/focus/FocusEmptyState';
|
||||
import { Icon } from '../components/Icon';
|
||||
|
||||
interface FocusPageProps {
|
||||
onExit: () => void;
|
||||
@@ -38,7 +39,7 @@ export function FocusPage({ onExit }: FocusPageProps) {
|
||||
} else if (isError) {
|
||||
body = (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@@ -46,7 +47,7 @@ export function FocusPage({ onExit }: FocusPageProps) {
|
||||
body = <FocusEmptyState onBack={onExit} />;
|
||||
} else {
|
||||
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} />
|
||||
</main>
|
||||
);
|
||||
@@ -55,18 +56,12 @@ export function FocusPage({ onExit }: FocusPageProps) {
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-earth animate-pulse" aria-hidden="true" />
|
||||
<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>
|
||||
<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">
|
||||
<button onClick={onExit} className="tool-btn !text-paper-white" aria-label="Quitter le mode Focus" title="Quitter le mode Focus (Échap)">
|
||||
<Icon name="close" size={22} strokeWidth={2} />
|
||||
</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>
|
||||
{body}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { persist } from 'zustand/middleware';
|
||||
export type ThemePref = 'system' | 'light' | 'sepia' | 'dark';
|
||||
export type ReaderFont = 'serif' | 'sans' | 'legible';
|
||||
export type ReaderWidth = 'narrow' | 'medium' | 'wide';
|
||||
/** List rows with a thumbnail, or a magazine grid of cards. */
|
||||
export type ListView = 'list' | 'magazine';
|
||||
|
||||
interface PrefsState {
|
||||
theme: ThemePref;
|
||||
@@ -14,12 +16,17 @@ interface PrefsState {
|
||||
font: ReaderFont;
|
||||
/** Dashboard filter: show only unread articles. */
|
||||
unreadOnly: boolean;
|
||||
view: ListView;
|
||||
/** AI summary block expanded at the top of the reader. */
|
||||
summaryOpen: boolean;
|
||||
setTheme: (t: ThemePref) => void;
|
||||
setFontSize: (n: number) => void;
|
||||
setLineHeight: (n: number) => void;
|
||||
setWidth: (w: ReaderWidth) => void;
|
||||
setFont: (f: ReaderFont) => void;
|
||||
setUnreadOnly: (v: boolean) => void;
|
||||
setView: (v: ListView) => void;
|
||||
setSummaryOpen: (v: boolean) => void;
|
||||
resetReader: () => void;
|
||||
}
|
||||
|
||||
@@ -30,9 +37,9 @@ export const FONT_SIZE_MIN = 15;
|
||||
export const FONT_SIZE_MAX = 26;
|
||||
|
||||
const THEME_COLORS: Record<Exclude<ThemePref, 'system'>, string> = {
|
||||
light: '#F7F4EC',
|
||||
sepia: '#F4ECD8',
|
||||
dark: '#121612',
|
||||
light: '#F6F4EE',
|
||||
sepia: '#F1E7D0',
|
||||
dark: '#0F1311',
|
||||
};
|
||||
|
||||
function resolveTheme(pref: ThemePref): Exclude<ThemePref, 'system'> {
|
||||
@@ -62,6 +69,8 @@ export const usePrefs = create<PrefsState>()(
|
||||
theme: 'system',
|
||||
...READER_DEFAULTS,
|
||||
unreadOnly: true,
|
||||
view: 'list',
|
||||
summaryOpen: true,
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
@@ -71,6 +80,8 @@ export const usePrefs = create<PrefsState>()(
|
||||
setWidth: (width) => set({ width }),
|
||||
setFont: (font) => set({ font }),
|
||||
setUnreadOnly: (unreadOnly) => set({ unreadOnly }),
|
||||
setView: (view) => set({ view }),
|
||||
setSummaryOpen: (summaryOpen) => set({ summaryOpen }),
|
||||
resetReader: () => set(READER_DEFAULTS),
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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
@@ -17,8 +17,8 @@ export default defineConfig({
|
||||
short_name: 'FlowReader',
|
||||
description: 'Lecteur RSS minimaliste et rapide',
|
||||
lang: 'fr',
|
||||
theme_color: '#15643A',
|
||||
background_color: '#F7F4EC',
|
||||
theme_color: '#1B6B43',
|
||||
background_color: '#F6F4EE',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
|
||||
Reference in new issue
Block a user