From 3e740541e5251d060c99ba010061ae985ee020bf Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Fri, 9 Oct 2026 08:43:23 +0200 Subject: [PATCH] feat(web): make the page title the feed selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row of feed chips is gone. The title in the top bar ("Tous les articles ▾") now says what is shown and opens the feed list: all articles, favorites, each feed with its unread count, add a feed, manage feeds. Desktop shows an anchored menu, phones a bottom sheet; arrow keys, Escape and focus return are supported. The dashboard keeps a single quiet toolbar (unread count, Non lus/Tous, refresh, mark all read). Theme, OPML export, keyboard shortcuts and logout live in the ⋯ menu. Co-Authored-By: Claude Opus 5.5 --- web/src/components/TopBar.tsx | 403 +++++++++++++++++++++++--------- web/src/pages/DashboardPage.tsx | 24 +- 2 files changed, 306 insertions(+), 121 deletions(-) diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx index b4a4ac8..1753acd 100644 --- a/web/src/components/TopBar.tsx +++ b/web/src/components/TopBar.tsx @@ -3,9 +3,11 @@ 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 { ThemeToggle } from './ThemeToggle'; +import { ShortcutsHelp } from './ShortcutsHelp'; interface TopBarProps { onSelectFeed: (feedId: string | null) => void; @@ -13,15 +15,76 @@ interface TopBarProps { onEnterFocus: () => void; } -/** Single navigation bar for every screen size (no sidebar). */ +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 [menuOpen, setMenuOpen] = useState(false); - const menuRef = useRef(null); + 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({ @@ -34,126 +97,251 @@ export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarPro }, }); - // Close the account menu on outside click / Escape. + 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 (!menuOpen) return; - const onDown = (e: MouseEvent) => { - if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setMenuOpen(false); - }; - document.addEventListener('mousedown', onDown); - window.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDown); - window.removeEventListener('keydown', onKey); - }; + 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 items = [ - { id: null as string | null, label: 'Tous', unread: totalUnread }, - { id: 'favorites' as string | null, label: 'Favoris', unread: 0 }, - ...(feeds ?? []).map((f) => ({ id: f.id as string | null, label: f.title, unread: f.unread_count ?? 0, error: !!f.fetch_error })), + 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 &&