mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
8277c28f68
commit
8bc74b4f52
6 files changed
+300
-316
No files matched your search
@@ -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 (
|
||||
<li className="flex items-center gap-2 py-2.5 border-b border-paper-muted/10 last:border-0">
|
||||
{editing ? (
|
||||
<form
|
||||
className="flex-1 flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) rename.mutate(title.trim());
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="input-field !py-2 text-sm"
|
||||
aria-label="Nom du flux"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="btn-primary !min-h-9 !px-4" disabled={rename.isPending}>
|
||||
Enregistrer
|
||||
</button>
|
||||
<button type="button" className="btn-ghost !min-h-9" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-paper-white truncate">{feed.title}</p>
|
||||
<p className={`text-xs truncate ${feed.fetch_error ? 'text-danger' : 'text-paper-muted'}`}>
|
||||
{feed.fetch_error ? `Erreur : ${feed.fetch_error}` : feed.url}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-ghost !min-h-9 !px-3" onClick={() => setEditing(true)}>
|
||||
Renommer
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost !min-h-9 !px-3 hover:!text-danger hover:!bg-danger/10"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Supprimer « ${feed.title} » et ses articles ?`)) remove.mutate();
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start sm:items-center justify-center p-4 bg-nature-dark/50 animate-fade-in overflow-y-auto"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="manage-feeds-title"
|
||||
>
|
||||
<div className="w-full max-w-xl surface-card p-6 animate-rise">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 id="manage-feeds-title" className="text-2xl font-serif text-paper-white">Mes 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.8} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{feeds && feeds.length > 0 ? (
|
||||
<ul>
|
||||
{feeds.map((f) => (
|
||||
<FeedRow key={f.id} feed={f} onDeleted={onDeleted} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-paper-muted text-sm">Aucun flux. Ajoutez-en un avec le bouton +.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<header className="md:hidden sticky top-0 z-30 bg-carbon-light/95 border-b border-paper-muted/12 px-4 py-2.5" style={{ paddingTop: 'max(0.625rem, env(safe-area-inset-top))' }}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={() => onSelectFeed(null)} className="text-nature text-xl font-serif italic shrink-0">
|
||||
FlowReader
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onEnterFocus} className="icon-btn" title="Mode Focus" aria-label="Mode Focus">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<button onClick={() => setIsAddOpen(true)} className="icon-btn" title="Ajouter un flux" aria-label="Ajouter un flux">
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed selector chips */}
|
||||
<nav className="flex gap-2 mt-2.5 overflow-x-auto pb-1 -mx-1 px-1 no-scrollbar" aria-label="Flux">
|
||||
{[
|
||||
{ id: null as string | null, label: 'Tous', unread: 0 },
|
||||
{ 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 })),
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.id ?? 'all'}
|
||||
onClick={() => onSelectFeed(item.id)}
|
||||
aria-current={selectedFeedId === item.id ? 'page' : undefined}
|
||||
className={`shrink-0 min-h-10 px-3.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
selectedFeedId === item.id
|
||||
? 'bg-nature text-on-nature'
|
||||
: 'bg-nature/10 text-paper-muted hover:text-nature'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{item.unread ? <span className="ml-1.5 opacity-80">{item.unread}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{isAddOpen && <AddFeedModal onClose={() => setIsAddOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<aside className="w-72 hidden md:flex bg-carbon-light/60 border-r border-paper-muted/12 h-dvh flex-col z-20" aria-label="Navigation">
|
||||
{/* Brand + actions */}
|
||||
<div className="p-6 pb-3">
|
||||
<div className="flex items-center justify-between mb-7">
|
||||
<button
|
||||
onClick={() => onSelectFeed(null)}
|
||||
className="text-nature text-2xl font-serif tracking-tight hover:opacity-80 transition-opacity"
|
||||
>
|
||||
FlowReader
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<button onClick={() => setIsAddModalOpen(true)} className="icon-btn" title="Ajouter un flux" aria-label="Ajouter un flux">
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
className="btn-secondary w-full"
|
||||
title="Tout marquer comme lu"
|
||||
>
|
||||
<svg className={`w-4 h-4 ${markAllReadMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
{markAllReadMutation.isPending
|
||||
? <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" />
|
||||
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />}
|
||||
</svg>
|
||||
{markAllReadMutation.isPending ? 'Traitement…' : 'Tout marquer lu'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
|
||||
<div className="group/item">
|
||||
<button onClick={() => onSelectFeed(null)} className={navItem(selectedFeedId === null && !isFocusMode)} aria-current={selectedFeedId === null && !isFocusMode ? 'page' : undefined}>
|
||||
<span className="text-sm font-medium flex items-center truncate">
|
||||
<span className={dot(selectedFeedId === null)} />Tous les articles
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="group/item">
|
||||
<button onClick={() => onSelectFeed('favorites')} className={navItem(selectedFeedId === 'favorites')} aria-current={selectedFeedId === 'favorites' ? 'page' : undefined}>
|
||||
<span className="text-sm font-medium flex items-center truncate">
|
||||
<span className={dot(selectedFeedId === 'favorites')} />Mes favoris
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="group/item pb-2">
|
||||
<button
|
||||
onClick={onEnterFocus}
|
||||
className={`w-full flex items-center px-4 py-2.5 rounded-xl text-left transition-all duration-300 ${
|
||||
isFocusMode
|
||||
? 'bg-nature text-on-nature shadow-lg shadow-nature/20'
|
||||
: 'bg-nature/5 text-nature border border-nature/20 hover:bg-nature hover:text-on-nature'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center">
|
||||
<svg className="w-4 h-4 mr-3" 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>
|
||||
Mode Focus
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="px-4 pt-5 pb-2 text-sm font-semibold text-paper-muted">Flux</h3>
|
||||
|
||||
{feeds && feeds.length > 0 ? feeds.map((feed) => (
|
||||
<div key={feed.id} className="group/item relative flex items-center">
|
||||
<button
|
||||
onClick={() => onSelectFeed(feed.id)}
|
||||
className={`${navItem(selectedFeedId === feed.id)} pr-20`}
|
||||
aria-current={selectedFeedId === feed.id ? 'page' : undefined}
|
||||
title={feed.fetch_error ? `Erreur : ${feed.fetch_error}` : feed.title}
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center min-w-0">
|
||||
<span className={dot(selectedFeedId === feed.id)} />
|
||||
<span className="truncate">{feed.title}</span>
|
||||
{feed.fetch_error && <span className="ml-2 text-danger shrink-0" aria-label="Erreur de récupération">!</span>}
|
||||
</span>
|
||||
{(feed.unread_count ?? 0) > 0 && (
|
||||
<span className="bg-nature/15 text-nature text-[11px] px-1.5 py-0.5 rounded-full min-w-[1.5rem] text-center font-bold shrink-0 group-hover/item:opacity-0 group-focus-within/item:opacity-0">
|
||||
{feed.unread_count}
|
||||
<span className="sr-only"> non lus</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="absolute right-2 flex items-center opacity-0 group-hover/item:opacity-100 group-focus-within/item:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleRename(feed.id, feed.title)}
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full text-nature hover:bg-nature/10"
|
||||
title="Renommer le flux"
|
||||
aria-label={`Renommer ${feed.title}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(feed.id)}
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full text-danger hover:bg-danger/10"
|
||||
title="Supprimer le flux"
|
||||
aria-label={`Supprimer ${feed.title}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="px-4 py-6 text-sm text-paper-muted italic font-reading">
|
||||
Aucun flux pour l'instant. Ajoutez-en un avec le bouton +.
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Footer: user + logout */}
|
||||
<div className="p-4 border-t border-paper-muted/12">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-paper-white truncate">{user?.email ?? 'Compte'}</p>
|
||||
<p className="text-xs text-paper-muted">
|
||||
{user?.is_admin ? 'Administrateur' : 'Lecteur'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
className="icon-btn shrink-0"
|
||||
title="Se déconnecter"
|
||||
aria-label="Se déconnecter"
|
||||
>
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{isAddModalOpen && <AddFeedModal onClose={() => setIsAddModalOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<>
|
||||
<header
|
||||
className="sticky top-0 z-30 bg-carbon-light/95 border-b border-paper-muted/12"
|
||||
style={{ paddingTop: 'env(safe-area-inset-top)' }}
|
||||
>
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12">
|
||||
<div className="flex items-center justify-between gap-3 h-14">
|
||||
<button onClick={() => onSelectFeed(null)} className="text-nature text-xl font-serif shrink-0">
|
||||
FlowReader
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={onEnterFocus} className="icon-btn" title="Mode Focus : trier les non lus" aria-label="Mode Focus">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<button onClick={() => setAddOpen(true)} className="icon-btn" title="Ajouter un flux" aria-label="Ajouter un flux">
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
className="icon-btn"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Compte et réglages"
|
||||
title="Compte et réglages"
|
||||
>
|
||||
<svg className="w-4 h-4" 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 role="menu" className="absolute right-0 top-12 w-60 surface-card py-1 z-50 animate-fade-in">
|
||||
{user && <p className="px-4 py-2.5 text-xs text-paper-muted truncate border-b border-paper-muted/10">{user.email}</p>}
|
||||
<button
|
||||
role="menuitem"
|
||||
className="w-full text-left px-4 min-h-11 text-sm text-paper-white hover:bg-nature/10"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setManageOpen(true);
|
||||
}}
|
||||
>
|
||||
Gérer mes flux
|
||||
</button>
|
||||
<a
|
||||
role="menuitem"
|
||||
href="/api/v1/feeds/export/opml"
|
||||
className="flex items-center w-full px-4 min-h-11 text-sm text-paper-white hover:bg-nature/10"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Exporter en OPML
|
||||
</a>
|
||||
<button
|
||||
role="menuitem"
|
||||
className="w-full text-left px-4 min-h-11 text-sm text-danger hover:bg-danger/10 border-t border-paper-muted/10"
|
||||
onClick={() => logout.mutate()}
|
||||
>
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feeds */}
|
||||
<nav className="flex gap-2 pb-3 overflow-x-auto no-scrollbar -mx-1 px-1" aria-label="Flux">
|
||||
{items.map((item) => {
|
||||
const active = selectedFeedId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id ?? 'all'}
|
||||
onClick={() => onSelectFeed(item.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
title={'error' in item && item.error ? 'Ce flux ne se met plus à jour' : undefined}
|
||||
className={`shrink-0 inline-flex items-center gap-1.5 min-h-10 px-3.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
active ? 'bg-nature text-on-nature' : 'bg-nature/10 text-paper-white/80 hover:text-nature'
|
||||
}`}
|
||||
>
|
||||
{'error' in item && item.error && <span className="w-1.5 h-1.5 rounded-full bg-danger" aria-hidden="true" />}
|
||||
{item.label}
|
||||
{item.unread > 0 && <span className={active ? 'opacity-80' : 'text-paper-muted'}>{item.unread}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{addOpen && <AddFeedModal onClose={() => setAddOpen(false)} />}
|
||||
{manageOpen && (
|
||||
<ManageFeedsModal
|
||||
onClose={() => setManageOpen(false)}
|
||||
onDeleted={(id) => {
|
||||
if (selectedFeedId === id) onSelectFeed(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-dvh bg-carbon overflow-hidden">
|
||||
<div className="flex flex-col 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>
|
||||
<Sidebar
|
||||
onSelectFeed={setSelectedFeedId}
|
||||
selectedFeedId={selectedFeedId}
|
||||
onEnterFocus={() => setIsFocusMode(true)}
|
||||
isFocusMode={isFocusMode}
|
||||
/>
|
||||
{!isFocusMode && (
|
||||
<TopBar selectedFeedId={selectedFeedId} onSelectFeed={setSelectedFeedId} onEnterFocus={() => setIsFocusMode(true)} />
|
||||
)}
|
||||
|
||||
<div id="main-content" className="flex-1 flex flex-col min-w-0">
|
||||
{!isFocusMode && (
|
||||
<MobileTopBar
|
||||
onSelectFeed={setSelectedFeedId}
|
||||
selectedFeedId={selectedFeedId}
|
||||
onEnterFocus={() => setIsFocusMode(true)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -226,7 +226,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
|
||||
return (
|
||||
<main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}>
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12 py-8 md:py-12">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12 py-6 md:py-8">
|
||||
<header className="mb-8 flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-3xl md:text-4xl font-serif text-paper-white tracking-tight break-words">{heading}</h1>
|
||||
|
||||
Reference in new issue
Block a user