diff --git a/README.md b/README.md index 639c2a2..2068363 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,13 @@ les comptes, sessions, flux et articles existants sont conservés. ### Lecture +- Bureau (≥ 1280 px) : trois panneaux — flux, liste, lecteur. Tablette : liste et lecteur côte à côte, + les flux dans un tiroir. Téléphone : une colonne avec une barre d'onglets (À lire, Focus, Favoris, Flux) + et des gestes sur les articles (glisser → lu, glisser ← favori). +- Vue **liste** (vignettes carrées) ou **magazine** (grandes cartes), au choix. +- Recherche dans les titres et le texte des articles : `⌘K` / `Ctrl+K` ou `/`. - Raccourcis clavier : `j`/`k` naviguer, `o` ouvrir, `m` lu/non lu, `s` favori, `v` original, - `/` rechercher, `u` non lus/tous, `r` actualiser, `Espace` page suivante puis article suivant, + `/` ou `⌘K` rechercher, `u` non lus/tous, `r` actualiser, `Espace` page suivante puis article suivant, `?` aide. - Bouton **Aa** dans le lecteur : thème (clair, sépia, sombre, auto), police (serif, sans, Atkinson Hyperlegible), taille, interligne et largeur de colonne. diff --git a/web/index.html b/web/index.html index 7442ccf..95fab06 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - + diff --git a/web/package-lock.json b/web/package-lock.json index 84219f8..7abb333 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/package.json b/web/package.json index 5f9fbe5..1ba7abc 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 473ff32..619de5e 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,5 +1,5 @@ - - F + + F diff --git a/web/src/components/AddFeedModal.tsx b/web/src/components/AddFeedModal.tsx index f8e9cfd..6d7e2a1 100644 --- a/web/src/components/AddFeedModal.tsx +++ b/web/src/components/AddFeedModal.tsx @@ -51,15 +51,15 @@ export function AddFeedModal({ onClose }: AddFeedModalProps) { return (
-
e.stopPropagation()}> +
e.stopPropagation()}>
-

Nouveau flux

+

Ajouter un flux

- {article.ai_summary ? ( -

- ✨ - {article.ai_summary} -

- ) : article.excerpt ? ( -

{article.excerpt}

- ) : null} + {(article.ai_summary || article.excerpt) && ( +

{article.ai_summary ? summaryText(article.ai_summary) : article.excerpt}

+ )} -
-

- - - {readingTimeLabel(article.reading_time)} -

+
+ {article.ai_summary && ( + + + Résumé IA + + )} + {readingTimeLabel(article.reading_time)} +
diff --git a/web/src/components/ArticleRow.tsx b/web/src/components/ArticleRow.tsx new file mode 100644 index 0000000..c921aa3 --- /dev/null +++ b/web/src/components/ArticleRow.tsx @@ -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 ( + + setLoaded(true)} + onError={() => setFailed(true)} + /> + + ); +} + +/** + * 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 ( +
+ {/* Action revealed behind the row while swiping */} + {offset !== 0 && ( + + )} + +
+
+
+ ); +} + +/** Memoised: a row re-renders only when its own article or selection changes. */ +export const ArticleRow = memo(ArticleRowImpl); diff --git a/web/src/components/AuthShell.tsx b/web/src/components/AuthShell.tsx index 1489b42..7b12c9f 100644 --- a/web/src/components/AuthShell.tsx +++ b/web/src/components/AuthShell.tsx @@ -16,20 +16,13 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
{/* Brand panel */} {/* Form side */} -
+
-
- FlowReader +
+
-
+

{eyebrow}

-

{title}

+

{title}

{subtitle}

{children} diff --git a/web/src/components/FeedAvatar.tsx b/web/src/components/FeedAvatar.tsx new file mode 100644 index 0000000..68d8a5e --- /dev/null +++ b/web/src/components/FeedAvatar.tsx @@ -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 ( + + ); +} diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx new file mode 100644 index 0000000..e6b4f62 --- /dev/null +++ b/web/src/components/Icon.tsx @@ -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: <>, + inbox: <>, + star: , + bolt: , + plus: , + check: , + checkAll: <>, + refresh: <>, + share: <>, + external: <>, + chevronLeft: , + chevronRight: , + chevronUp: , + chevronDown: , + arrowRight: , + arrowUp: , + close: , + sparkle: <>, + clock: <>, + undo: <>, + book: <>, + list: <>, + grid: <>, + sliders: <>, + rss: <>, + moon: , + sun: <>, + alert: <>, + menu: , + eye: <>, + eyeOff: <>, + lock: <>, +} 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 ( + + + + + + ); + } + return ( + + {PATHS[name]} + + ); +} diff --git a/web/src/components/MobileTabBar.tsx b/web/src/components/MobileTabBar.tsx new file mode 100644 index 0000000..18a0a0d --- /dev/null +++ b/web/src/components/MobileTabBar.tsx @@ -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 ( + + ); +} diff --git a/web/src/components/SearchPanel.tsx b/web/src/components/SearchPanel.tsx new file mode 100644 index 0000000..3965221 --- /dev/null +++ b/web/src/components/SearchPanel.tsx @@ -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 . */ +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)} + {text.slice(i, i + q.length)} + {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(null); + const listRef = useRef(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( +
{ + if (e.target === e.currentTarget) close(); + }} + > +
+
+
+ + + 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" + /> +
+ + Échap +
+ +
+ {q.length < 2 ? ( + recent.length > 0 ? ( +
+

Recherches récentes

+
    + {recent.map((r) => ( +
  • + +
  • + ))} +
+
+ ) : ( +

Cherchez un mot dans les titres et le texte de vos articles.

+ ) + ) : isError ? ( +

+ {navigator.onLine ? 'La recherche a échoué. Réessayez.' : 'La recherche demande une connexion.'} +

+ ) : hits.length === 0 ? ( +
+ {isFetching ? ( +
+ ) : ( + <> +

Aucun article trouvé

+

Essayez un autre mot.

+ + )} +
+ ) : ( + <> +

+ {hits.length} + {hits.length >= 30 ? '+' : ''} résultat{hits.length > 1 ? 's' : ''} +

+
    + {hits.map((a, i) => { + const date = a.published_at || a.created_at; + return ( +
  • 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' : ''}`} + > + + + {a.feed_title} + + + + + + + {(a.excerpt || a.ai_summary) && ( + + + + )} +
  • + ); + })} +
+ + )} +
+
+
, + document.body, + ); +} diff --git a/web/src/components/ShareButton.tsx b/web/src/components/ShareButton.tsx index 7f71268..2f66bb7 100644 --- a/web/src/components/ShareButton.tsx +++ b/web/src/components/ShareButton.tsx @@ -1,12 +1,16 @@ import { useEffect, useRef, useState } from 'react'; import type { Article } from '../api/articles'; +import { Icon } from './Icon'; interface ShareButtonProps { article: Pick; 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(null); @@ -71,9 +75,7 @@ export function ShareButton({ article, className = 'icon-btn' }: ShareButtonProp aria-haspopup="menu" aria-expanded={isOpen} > - + {copied && ( Lien copié @@ -85,23 +87,23 @@ export function ShareButton({ article, className = 'icon-btn' }: ShareButtonProp
{items.map((it) => ( ))} -
+
diff --git a/web/src/components/ShortcutsHelp.tsx b/web/src/components/ShortcutsHelp.tsx index 3cda949..81b3f08 100644 --- a/web/src/components/ShortcutsHelp.tsx +++ b/web/src/components/ShortcutsHelp.tsx @@ -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()} > -

Raccourcis clavier

+

Raccourcis clavier

{GROUPS.map((g) => (
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..156b2de --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -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(null); + const menuPanel = useRef(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 ( + + ); +} diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx deleted file mode 100644 index 6bc6631..0000000 --- a/web/src/components/ThemeToggle.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx deleted file mode 100644 index f328674..0000000 --- a/web/src/components/TopBar.tsx +++ /dev/null @@ -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[]) { - 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) { - if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return; - const items = Array.from(e.currentTarget.querySelectorAll('[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 = () => ( - -); - -/** - * 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(null); - const scopePanel = useRef(null); - const menuBtn = useRef(null); - const menuPanel = useRef(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('[aria-checked="true"]') ?? scopePanel.current?.querySelector('[role="menuitemradio"]'); - el?.focus(); - }, [scopeOpen]); - useEffect(() => { - if (menuOpen) menuPanel.current?.querySelector('[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 ( - - ); - }; - - const scopeList = ( -
- {isMobile &&