From 8bc74b4f5263dc100865b43fbc6c017f01c90d1c Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Fri, 9 Oct 2026 08:29:38 +0200 Subject: [PATCH] feat(web): replace the sidebar with a full-width top bar - One top bar for every screen size: feeds as chips with unread counts, Focus mode, theme, add feed, and a menu for managing feeds, OPML export and logout. The article grid now uses the full page width. - New "Mes flux" dialog to rename or delete feeds (was hidden behind hover actions in the sidebar); feeds that fail to update are flagged. Co-Authored-By: Claude Opus 5.5 --- web/src/components/ManageFeedsModal.tsx | 125 +++++++++++++ web/src/components/MobileTopBar.tsx | 67 ------- web/src/components/Sidebar.tsx | 231 ------------------------ web/src/components/TopBar.tsx | 168 +++++++++++++++++ web/src/layouts/RootLayout.tsx | 23 +-- web/src/pages/DashboardPage.tsx | 2 +- 6 files changed, 300 insertions(+), 316 deletions(-) create mode 100644 web/src/components/ManageFeedsModal.tsx delete mode 100644 web/src/components/MobileTopBar.tsx delete mode 100644 web/src/components/Sidebar.tsx create mode 100644 web/src/components/TopBar.tsx diff --git a/web/src/components/ManageFeedsModal.tsx b/web/src/components/ManageFeedsModal.tsx new file mode 100644 index 0000000..da874e8 --- /dev/null +++ b/web/src/components/ManageFeedsModal.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { feedsApi, type Feed } from '../api/feeds'; + +interface ManageFeedsModalProps { + onClose: () => void; + /** Called when the currently selected feed is deleted. */ + onDeleted: (id: string) => void; +} + +function FeedRow({ feed, onDeleted }: { feed: Feed; onDeleted: (id: string) => void }) { + const qc = useQueryClient(); + const [editing, setEditing] = useState(false); + const [title, setTitle] = useState(feed.title); + + const rename = useMutation({ + mutationFn: (t: string) => feedsApi.update(feed.id, t), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['feeds'] }); + setEditing(false); + }, + }); + const remove = useMutation({ + mutationFn: () => feedsApi.delete(feed.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['feeds'] }); + qc.invalidateQueries({ queryKey: ['articles'] }); + onDeleted(feed.id); + }, + }); + + return ( +
  • + {editing ? ( +
    { + e.preventDefault(); + if (title.trim()) rename.mutate(title.trim()); + }} + > + setTitle(e.target.value)} + className="input-field !py-2 text-sm" + aria-label="Nom du flux" + autoFocus + /> + + +
    + ) : ( + <> +
    +

    {feed.title}

    +

    + {feed.fetch_error ? `Erreur : ${feed.fetch_error}` : feed.url} +

    +
    + + + + )} +
  • + ); +} + +/** Rename and delete subscriptions (formerly in the sidebar). */ +export function ManageFeedsModal({ onClose, onDeleted }: ManageFeedsModalProps) { + const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() }); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
    { + if (e.target === e.currentTarget) onClose(); + }} + role="dialog" + aria-modal="true" + aria-labelledby="manage-feeds-title" + > +
    +
    +

    Mes flux

    + +
    + {feeds && feeds.length > 0 ? ( +
      + {feeds.map((f) => ( + + ))} +
    + ) : ( +

    Aucun flux. Ajoutez-en un avec le bouton +.

    + )} +
    +
    + ); +} diff --git a/web/src/components/MobileTopBar.tsx b/web/src/components/MobileTopBar.tsx deleted file mode 100644 index f62434f..0000000 --- a/web/src/components/MobileTopBar.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { feedsApi } from '../api/feeds'; -import { AddFeedModal } from './AddFeedModal'; -import { ThemeToggle } from './ThemeToggle'; - -interface MobileTopBarProps { - onSelectFeed: (feedId: string | null) => void; - selectedFeedId: string | null; - onEnterFocus: () => void; -} - -/** Compact navigation for small screens (the full Sidebar is hidden on mobile). */ -export function MobileTopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: MobileTopBarProps) { - const [isAddOpen, setIsAddOpen] = useState(false); - const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() }); - - return ( - <> -
    -
    - -
    - - - -
    -
    - - {/* Feed selector chips */} - -
    - - {isAddOpen && setIsAddOpen(false)} />} - - ); -} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx deleted file mode 100644 index 56b5e8c..0000000 --- a/web/src/components/Sidebar.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useAuthStore, clearUserData } from '../stores/authStore'; -import { authApi } from '../api/auth'; -import { feedsApi } from '../api/feeds'; -import { articlesApi } from '../api/articles'; -import { AddFeedModal } from './AddFeedModal'; -import { ThemeToggle } from './ThemeToggle'; - -interface SidebarProps { - onSelectFeed: (feedId: string | null) => void; - selectedFeedId: string | null; - onEnterFocus: () => void; - isFocusMode: boolean; -} - -export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMode }: SidebarProps) { - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const queryClient = useQueryClient(); - const user = useAuthStore((s) => s.user); - const logoutStore = useAuthStore((s) => s.logout); - - const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() }); - - const markAllReadMutation = useMutation({ - mutationFn: () => - selectedFeedId && selectedFeedId !== 'favorites' - ? articlesApi.markAllRead(selectedFeedId) - : articlesApi.markAllReadGlobal(), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['feeds'] }); - queryClient.invalidateQueries({ queryKey: ['articles'] }); - }, - }); - - const deleteFeedMutation = useMutation({ - mutationFn: (id: string) => feedsApi.delete(id), - onSuccess: (_, deletedId) => { - queryClient.invalidateQueries({ queryKey: ['feeds'] }); - if (selectedFeedId === deletedId) onSelectFeed(null); - }, - }); - - const updateFeedMutation = useMutation({ - mutationFn: ({ id, title }: { id: string; title: string }) => feedsApi.update(id, title), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['feeds'] }), - }); - - const logoutMutation = useMutation({ - mutationFn: () => authApi.logout(), - onSettled: async () => { - logoutStore(); - queryClient.clear(); - await clearUserData(); - window.location.href = '/login'; - }, - }); - - const handleDelete = (id: string) => { - if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) deleteFeedMutation.mutate(id); - }; - - const handleRename = (id: string, currentTitle: string) => { - const newTitle = window.prompt('Renommer le flux :', currentTitle); - if (newTitle && newTitle.trim() !== '' && newTitle !== currentTitle) { - updateFeedMutation.mutate({ id, title: newTitle.trim() }); - } - }; - - const navItem = (active: boolean) => - `w-full flex items-center justify-between px-4 py-2.5 rounded-xl text-left transition-all duration-300 ${ - active - ? 'bg-nature/10 text-nature border border-nature/20' - : 'text-paper-muted border border-transparent hover:text-nature hover:bg-nature/5' - }`; - - const dot = (active: boolean) => - `w-1.5 h-1.5 rounded-full bg-nature mr-3 transition-opacity ${active ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100'}`; - - return ( - <> - - - {isAddModalOpen && setIsAddModalOpen(false)} />} - - ); -} diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx new file mode 100644 index 0000000..b4a4ac8 --- /dev/null +++ b/web/src/components/TopBar.tsx @@ -0,0 +1,168 @@ +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 { AddFeedModal } from './AddFeedModal'; +import { ManageFeedsModal } from './ManageFeedsModal'; +import { ThemeToggle } from './ThemeToggle'; + +interface TopBarProps { + onSelectFeed: (feedId: string | null) => void; + selectedFeedId: string | null; + onEnterFocus: () => void; +} + +/** Single navigation bar for every screen size (no sidebar). */ +export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarProps) { + const qc = useQueryClient(); + const [addOpen, setAddOpen] = useState(false); + const [manageOpen, setManageOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + const user = useAuthStore((s) => s.user); + const logoutStore = useAuthStore((s) => s.logout); + 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'; + }, + }); + + // Close the account menu on outside click / Escape. + 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); + }; + }, [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 })), + ]; + + return ( + <> +
    +
    +
    + +
    + + + +
    + + {menuOpen && ( +
    + {user &&

    {user.email}

    } + + setMenuOpen(false)} + > + Exporter en OPML + + +
    + )} +
    +
    +
    + + {/* Feeds */} + +
    +
    + + {addOpen && setAddOpen(false)} />} + {manageOpen && ( + setManageOpen(false)} + onDeleted={(id) => { + if (selectedFeedId === id) onSelectFeed(null); + }} + /> + )} + + ); +} diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index 8f42520..a739377 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -1,7 +1,6 @@ import { Suspense, useState } from 'react'; import { lazyNamed } from '../lib/lazy'; -import { Sidebar } from '../components/Sidebar'; -import { MobileTopBar } from '../components/MobileTopBar'; +import { TopBar } from '../components/TopBar'; import { DashboardPage } from '../pages/DashboardPage'; import { useWebsocket } from '../hooks/useWebsocket'; @@ -16,25 +15,15 @@ export function RootLayout() { useWebsocket(); return ( -
    +
    Aller au contenu - setIsFocusMode(true)} - isFocusMode={isFocusMode} - /> + {!isFocusMode && ( + setIsFocusMode(true)} /> + )} -
    - {!isFocusMode && ( - setIsFocusMode(true)} - /> - )} +
    {/* Kept mounted in focus mode so the list keeps its scroll position. */}
    diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 053945e..6be7e20 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -226,7 +226,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) { return (
    -
    +

    {heading}