mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-19 13:18:06 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd73b3d09d | ||
|
|
387e594221 | ||
|
|
081420ceb8 | ||
|
|
23563219d2 | ||
|
|
3e740541e5 | ||
|
|
96fe965e9e | ||
|
|
8bc74b4f52 | ||
|
|
8277c28f68 | ||
|
|
ed83525d26 | ||
|
|
b24a9806d5 |
No files matched your search
+5
-4
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { lazyNamed } from './lib/lazy';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useAuthStore, clearUserData } from './stores/authStore';
|
||||
@@ -8,9 +9,9 @@ import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
|
||||
// Route-level code splitting: the reader app and the auth screens load separately.
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })));
|
||||
const RegisterPage = lazy(() => import('./pages/RegisterPage').then((m) => ({ default: m.RegisterPage })));
|
||||
const RootLayout = lazy(() => import('./layouts/RootLayout').then((m) => ({ default: m.RootLayout })));
|
||||
const LoginPage = lazyNamed(() => import('./pages/LoginPage'), 'LoginPage');
|
||||
const RegisterPage = lazyNamed(() => import('./pages/RegisterPage'), 'RegisterPage');
|
||||
const RootLayout = lazyNamed(() => import('./layouts/RootLayout'), 'RootLayout');
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -15,9 +15,10 @@ interface ArticleCardProps {
|
||||
|
||||
/** Initial-letter cover used when a feed provides no image (no third-party fallback). */
|
||||
function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
||||
const initial = (feed || title).trim().charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-nature/90 to-nature-dark flex items-end p-5">
|
||||
<span className="font-serif italic text-on-nature/90 text-lg leading-snug line-clamp-3">{feed || title}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +26,7 @@ function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
||||
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
// Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text
|
||||
// selection) never trigger it.
|
||||
@@ -80,18 +82,21 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
|
||||
referrerPolicy="no-referrer"
|
||||
width={640}
|
||||
height={360}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
||||
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)}
|
||||
/>
|
||||
) : (
|
||||
<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-[11px] font-extrabold uppercase tracking-[0.18em] text-center py-1.5 shadow-lg shadow-black/20">
|
||||
<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>
|
||||
|
||||
@@ -38,7 +38,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">
|
||||
<h2 className="text-5xl font-serif italic leading-[1.1] 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">
|
||||
@@ -46,7 +46,7 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<p className="relative z-10 text-white/40 text-[10px] uppercase tracking-[0.4em] font-bold">
|
||||
<p className="relative z-10 text-white/50 text-xs">
|
||||
FlowReader · Édition 2026
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
@@ -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,43 +0,0 @@
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
|
||||
interface SearchBoxProps {
|
||||
onSearch: (query: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the raw input value so typing only re-renders this box; the list
|
||||
* receives the debounced query.
|
||||
*/
|
||||
export const SearchBox = forwardRef<HTMLInputElement, SearchBoxProps>(function SearchBox({ onSearch }, ref) {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => onSearch(value.trim()), 350);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [value, onSearch]);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<span className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-paper-muted group-focus-within:text-nature transition-colors">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
ref={ref}
|
||||
type="search"
|
||||
aria-label="Rechercher dans vos articles"
|
||||
placeholder="Rechercher… /"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setValue('');
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="input-field !py-2.5 pl-10 pr-4 !rounded-full w-full sm:w-56 sm:focus:w-72 transition-[width] text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -9,7 +9,6 @@ const GROUPS: { title: string; keys: [string, string][] }[] = [
|
||||
['m', 'Marquer lu / non lu'],
|
||||
['s · f', 'Favori'],
|
||||
['v', 'Ouvrir l’original'],
|
||||
['/', 'Rechercher'],
|
||||
['u', 'Basculer Non lus / Tous'],
|
||||
['r', 'Actualiser les flux'],
|
||||
],
|
||||
@@ -18,9 +17,9 @@ const GROUPS: { title: string; keys: [string, string][] }[] = [
|
||||
title: 'Lecture',
|
||||
keys: [
|
||||
['Espace', 'Défiler, puis article suivant'],
|
||||
['j · → / k · ←', 'Suivant / précédent'],
|
||||
['j · → / k · ←', 'Article suivant / précédent'],
|
||||
['+ / -', 'Taille du texte'],
|
||||
['Échap', 'Fermer'],
|
||||
['Échap', 'Fermer l’article'],
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -51,11 +50,11 @@ 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 italic text-nature mb-5">Raccourcis clavier</h2>
|
||||
<h2 id="shortcuts-title" className="text-2xl font-serif text-paper-white mb-5">Raccourcis clavier</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{GROUPS.map((g) => (
|
||||
<section key={g.title}>
|
||||
<h3 className="eyebrow text-paper-muted mb-3">{g.title}</h3>
|
||||
<h3 className="text-sm font-semibold text-paper-white mb-3">{g.title}</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
{g.keys.map(([k, label]) => (
|
||||
<div key={k} className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -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 italic 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-[11px] font-bold uppercase tracking-[0.18em] 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-4 pb-2 text-[11px] uppercase tracking-[0.3em] font-bold text-paper-muted">Mes 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-[11px] text-paper-muted uppercase tracking-widest font-bold">
|
||||
{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,357 @@
|
||||
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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,19 +24,19 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-nature to-nature-dark p-8 flex items-end">
|
||||
<span className="text-2xl font-serif italic text-on-nature/95 leading-tight line-clamp-3">{article.feed_title}</span>
|
||||
<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>
|
||||
)}
|
||||
<span className="absolute top-4 right-4 bg-black/55 text-white text-[11px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full">
|
||||
<span className="absolute top-4 right-4 bg-black/55 text-white text-xs font-semibold px-3 py-1.5 rounded-full">
|
||||
{readingTimeLabel(article.reading_time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 p-5 sm:p-6 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-3 text-[11px] uppercase tracking-widest font-bold text-paper-muted">
|
||||
<span className="flex items-center gap-2 text-nature truncate">
|
||||
<div className="flex items-center justify-between gap-3 mb-3 text-xs text-paper-muted">
|
||||
<span className="flex items-center gap-2 text-nature font-semibold truncate">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-nature shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{article.feed_title || 'Journal'}</span>
|
||||
</span>
|
||||
@@ -54,7 +54,7 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
|
||||
)}
|
||||
|
||||
{isTop && (
|
||||
<p className="mt-auto pt-3 text-center text-[11px] uppercase tracking-widest font-bold text-paper-muted hidden sm:block">
|
||||
<p className="mt-auto pt-3 text-center text-xs text-paper-muted hidden sm:block">
|
||||
← Garder · Lu → · Entrée pour lire
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -144,7 +144,7 @@ export function FocusCardStack({ articles, onEmpty, onExit }: FocusCardStackProp
|
||||
<div className="relative w-full h-full flex flex-col items-center justify-center gap-6">
|
||||
<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-[11px] uppercase font-bold tracking-widest">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -32,9 +32,16 @@ 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>
|
||||
);
|
||||
|
||||
/**
|
||||
* Full-screen, distraction-free reader used on desktop and mobile.
|
||||
* Rendered in a portal; the app behind it is made inert while it's open.
|
||||
* 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.
|
||||
*/
|
||||
export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, onPrev }: ReaderProps) {
|
||||
const qc = useQueryClient();
|
||||
@@ -45,7 +52,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
const font = usePrefs((s) => s.font);
|
||||
const setFontSize = usePrefs((s) => s.setFontSize);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -71,16 +78,19 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
const t = window.setTimeout(() => {
|
||||
qc.prefetchQuery({ queryKey: ['article', next.id], queryFn: () => articlesApi.get(next.id), staleTime: 5 * 60 * 1000 });
|
||||
if (next.image_url) new Image().src = next.image_url;
|
||||
}, 600);
|
||||
}, 500);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [next, qc]);
|
||||
|
||||
// Modal behaviour: make the app inert, move focus in, restore it on close.
|
||||
// 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.
|
||||
useLayoutEffect(() => {
|
||||
const root = document.getElementById('root');
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
root?.setAttribute('inert', '');
|
||||
document.body.style.overflow = 'hidden';
|
||||
overlayRef.current?.scrollTo({ top: 0 });
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
root?.removeAttribute('inert');
|
||||
document.body.style.overflow = '';
|
||||
@@ -88,26 +98,16 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
};
|
||||
}, []);
|
||||
|
||||
// New article: back to top, focus the title.
|
||||
useLayoutEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
setSummaryError('');
|
||||
}, [listArticle.id]);
|
||||
|
||||
// Reading progress without React re-renders.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
const el = overlayRef.current;
|
||||
if (!el) return;
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const max = el.scrollHeight - el.clientHeight;
|
||||
const ratio = max > 0 ? Math.min(1, el.scrollTop / max) : 1;
|
||||
if (progressRef.current) {
|
||||
progressRef.current.style.transform = `scaleX(${ratio})`;
|
||||
progressRef.current.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
|
||||
}
|
||||
if (progressRef.current) progressRef.current.style.transform = `scaleX(${ratio})`;
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update);
|
||||
@@ -124,34 +124,29 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
if (listArticle.url) window.open(listArticle.url, '_blank', 'noopener,noreferrer');
|
||||
}, [listArticle.url]);
|
||||
|
||||
// Keyboard shortcuts (Miniflux / NetNewsWire conventions).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return;
|
||||
const el = scrollRef.current;
|
||||
const el = overlayRef.current;
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'j':
|
||||
case 'n':
|
||||
case 'ArrowRight':
|
||||
if (onNext) onNext();
|
||||
break;
|
||||
case 'k':
|
||||
case 'p':
|
||||
case 'ArrowLeft':
|
||||
if (onPrev && hasPrev) onPrev();
|
||||
break;
|
||||
case 's':
|
||||
case 'f':
|
||||
toggleFavorite(listArticle);
|
||||
break;
|
||||
case 'm':
|
||||
toggleRead(listArticle);
|
||||
break;
|
||||
case 'v':
|
||||
case 'o':
|
||||
openOriginal();
|
||||
break;
|
||||
case '+':
|
||||
@@ -162,7 +157,6 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
setFontSize(fontSize - 1);
|
||||
break;
|
||||
case ' ': {
|
||||
// Space pages down; at the end it moves to the next article.
|
||||
if (!el || e.shiftKey) return;
|
||||
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
|
||||
if (atEnd && onNext) {
|
||||
@@ -180,17 +174,17 @@ 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]);
|
||||
|
||||
// Touch swipe between articles; ignored over horizontally scrollable content.
|
||||
// Optional touch shortcut on phones; the buttons below do the same.
|
||||
const swipe = useSwipeable({
|
||||
onSwipedLeft: (ev) => {
|
||||
if (!(ev.event.target as Element).closest('pre,table,[data-noswipe]') && onNext) onNext();
|
||||
if (!(ev.event.target as Element).closest('pre,table') && onNext) onNext();
|
||||
},
|
||||
onSwipedRight: (ev) => {
|
||||
if (!(ev.event.target as Element).closest('pre,table,[data-noswipe]') && hasPrev && onPrev) onPrev();
|
||||
if (!(ev.event.target as Element).closest('pre,table') && hasPrev && onPrev) onPrev();
|
||||
},
|
||||
trackMouse: false,
|
||||
delta: 80,
|
||||
swipeDuration: 500,
|
||||
delta: 90,
|
||||
swipeDuration: 450,
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
@@ -213,170 +207,156 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
'--reader-font': FONTS[font],
|
||||
letterSpacing: font === 'legible' ? '0.02em' : undefined,
|
||||
} as React.CSSProperties;
|
||||
const measure = MEASURES[width];
|
||||
|
||||
const date = listArticle.published_at || listArticle.created_at;
|
||||
|
||||
const body = (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-carbon animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reader-title"
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-nature-dark/45 backdrop-blur-sm animate-fade-in"
|
||||
onMouseDown={(e) => {
|
||||
// Click beside the card closes it.
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Progress */}
|
||||
<div className="sticky top-0 z-30 h-0.5 w-full bg-transparent">
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="h-full bg-nature origin-left will-change-transform"
|
||||
style={{ transform: 'scaleX(0)' }}
|
||||
role="progressbar"
|
||||
aria-label="Progression de lecture"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
</div>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="sticky top-0.5 z-20 bg-carbon/90 backdrop-blur-md border-b border-paper-muted/10">
|
||||
<div className="mx-auto max-w-5xl flex items-center gap-1.5 px-3 sm:px-6 py-2">
|
||||
<button onClick={onClose} className="icon-btn" aria-label="Fermer (Échap)" 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={1.8} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
<button onClick={onPrev} disabled={!hasPrev} className="icon-btn" aria-label="Article précédent (k)" title="Précédent (k)">
|
||||
<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="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={onNext} disabled={!onNext} className="icon-btn" aria-label="Article suivant (j)" title="Suivant (j)">
|
||||
<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="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={() => toggleRead(listArticle)}
|
||||
className="icon-btn"
|
||||
aria-pressed={!article.is_read}
|
||||
aria-label={article.is_read ? 'Marquer comme non lu (m)' : 'Marquer comme lu (m)'}
|
||||
title={article.is_read ? 'Marquer non lu (m)' : 'Marquer lu (m)'}
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleFavorite(listArticle)}
|
||||
className={`icon-btn ${article.is_favorite ? '!bg-earth !text-on-earth !border-earth' : ''}`}
|
||||
aria-pressed={article.is_favorite}
|
||||
aria-label="Favori (s)"
|
||||
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
</button>
|
||||
<ShareButton article={article} />
|
||||
<div className="relative">
|
||||
<article
|
||||
{...swipe}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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)' }}
|
||||
>
|
||||
{/* 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>
|
||||
<button
|
||||
onClick={() => setSettingsOpen((o) => !o)}
|
||||
className="icon-btn font-serif text-base"
|
||||
aria-expanded={settingsOpen}
|
||||
aria-label="Réglages de lecture"
|
||||
title="Réglages de lecture"
|
||||
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)'}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
{settingsOpen && <ReaderSettings onClose={() => setSettingsOpen(false)} />}
|
||||
</div>
|
||||
{listArticle.url && (
|
||||
<a href={listArticle.url} target="_blank" rel="noopener noreferrer" className="icon-btn" aria-label="Ouvrir l'article original (v)" title="Original (v)">
|
||||
<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 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>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article {...swipe} className="mx-auto max-w-5xl pb-28" style={style}>
|
||||
{listArticle.image_url && (
|
||||
<div className="px-0 sm:px-6 pt-0 sm:pt-6">
|
||||
<img
|
||||
src={listArticle.image_url}
|
||||
alt=""
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full max-h-[46vh] object-cover sm:rounded-3xl bg-carbon-dark"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
</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="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"
|
||||
aria-expanded={settingsOpen}
|
||||
aria-label="Affichage du texte"
|
||||
title="Affichage du texte"
|
||||
>
|
||||
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>
|
||||
<div className="h-0.5 bg-transparent">
|
||||
<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-6 mx-auto" style={{ maxWidth: measure }}>
|
||||
<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="flex flex-wrap items-center gap-x-3 gap-y-2 mb-5 text-sm text-paper-muted">
|
||||
<span className="chip">{listArticle.feed_title || 'Journal'}</span>
|
||||
<time dateTime={listArticle.published_at || listArticle.created_at}>
|
||||
{formatLong(listArticle.published_at || listArticle.created_at)}
|
||||
</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{readingTimeLabel(listArticle.reading_time)} de lecture</span>
|
||||
</div>
|
||||
|
||||
<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.9rem, 1.3rem + 2.4vw, 3rem)' }}
|
||||
style={{ fontSize: 'clamp(1.75rem, 1.3rem + 1.8vw, 2.6rem)' }}
|
||||
>
|
||||
{listArticle.title}
|
||||
</h1>
|
||||
{listArticle.author && <p className="mt-4 text-paper-muted text-sm">Par {listArticle.author}</p>}
|
||||
{listArticle.author && <p className="mt-3 text-paper-muted text-sm">Par {listArticle.author}</p>}
|
||||
|
||||
{/* AI digest */}
|
||||
<div className="mt-7">
|
||||
{article.ai_summary ? (
|
||||
<aside className="bg-nature/8 border-l-4 border-nature p-5 rounded-r-2xl" aria-label="Résumé IA">
|
||||
<h2 className="eyebrow mb-2">Résumé IA</h2>
|
||||
<p className="text-paper-white/90 leading-relaxed font-reading">{article.ai_summary}</p>
|
||||
</aside>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={summarize} disabled={summarizing} className="btn-secondary">
|
||||
{summarizing ? (
|
||||
<span className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<span aria-hidden="true">✨</span>
|
||||
)}
|
||||
{summarizing ? 'Génération…' : 'Résumer avec l’IA'}
|
||||
</button>
|
||||
{summaryError && <p role="alert" className="mt-2 text-sm text-danger">{summaryError}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{detail ? (
|
||||
html ? (
|
||||
<div className={`magazine-content ${font === 'serif' ? 'drop-cap' : ''}`} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<div className="magazine-content" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<p className="text-paper-muted italic">
|
||||
Ce flux ne publie qu’un titre. {listArticle.url && 'Ouvrez l’article original pour le lire en entier.'}
|
||||
</p>
|
||||
<p className="text-paper-muted italic">Ce flux ne publie que le titre de l’article.</p>
|
||||
)
|
||||
) : isError ? (
|
||||
<div role="alert" className="text-paper-muted">
|
||||
<p role="alert" className="text-paper-muted">
|
||||
Impossible de charger l’article.{' '}
|
||||
<button className="text-nature underline" onClick={() => refetch()}>Réessayer</button>
|
||||
</div>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4" aria-busy="true" aria-label="Chargement de l'article">
|
||||
{[92, 100, 85, 97, 60].map((w, i) => (
|
||||
@@ -386,32 +366,46 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
||||
)}
|
||||
|
||||
{listArticle.url && (
|
||||
<p className="mt-12">
|
||||
<a href={listArticle.url} target="_blank" rel="noopener noreferrer" className="btn-secondary">
|
||||
Lire sur le site d’origine
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Up next */}
|
||||
{next && onNext && (
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="mt-14 w-full text-left surface-card p-5 flex items-center gap-4 hover:border-nature/40 transition-colors group"
|
||||
<a
|
||||
href={listArticle.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-10 inline-flex items-center gap-2 text-sm font-semibold text-nature hover:underline"
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="eyebrow block mb-1">À suivre</span>
|
||||
<span className="block font-serif text-lg text-paper-white leading-snug line-clamp-2 group-hover:text-nature">
|
||||
{next.title}
|
||||
</span>
|
||||
<span className="block mt-1 text-xs text-paper-muted">
|
||||
{next.feed_title} · {readingTimeLabel(next.reading_time)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="kbd hidden sm:inline-flex" aria-hidden="true">Espace</span>
|
||||
</button>
|
||||
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>
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
<Chevron dir="right" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
|
||||
+13
-6
@@ -113,6 +113,13 @@
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Article grid: as many ~20rem columns as the width allows. */
|
||||
@utility article-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
/* Skip rendering work for off-screen cards in long lists. */
|
||||
@utility cv-auto {
|
||||
content-visibility: auto;
|
||||
@@ -189,21 +196,21 @@
|
||||
@layer components {
|
||||
/* Eyebrow / kicker label */
|
||||
.eyebrow {
|
||||
@apply text-[11px] uppercase tracking-[0.3em] font-bold text-nature;
|
||||
@apply text-sm font-semibold text-paper-muted;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-bold
|
||||
min-h-11 px-6 py-2.5 rounded-full text-xs uppercase tracking-[0.16em]
|
||||
@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)]
|
||||
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-bold
|
||||
min-h-11 px-5 py-2.5 rounded-full text-xs uppercase tracking-[0.16em]
|
||||
@apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-semibold
|
||||
min-h-11 px-5 py-2.5 rounded-full text-sm
|
||||
border border-nature/35 transition-all duration-200
|
||||
hover:bg-nature hover:text-on-nature hover:border-nature
|
||||
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
|
||||
@@ -246,7 +253,7 @@
|
||||
/* Chip / badge */
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full
|
||||
text-[11px] font-bold uppercase tracking-[0.14em]
|
||||
text-xs font-semibold
|
||||
bg-nature/10 text-nature;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { Sidebar } from '../components/Sidebar';
|
||||
import { MobileTopBar } from '../components/MobileTopBar';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { lazyNamed } from '../lib/lazy';
|
||||
import { TopBar } from '../components/TopBar';
|
||||
import { DashboardPage } from '../pages/DashboardPage';
|
||||
import { useWebsocket } from '../hooks/useWebsocket';
|
||||
|
||||
// Focus mode carries the gesture/animation code: load it only when used.
|
||||
const FocusPage = lazy(() => import('../pages/FocusPage').then((m) => ({ default: m.FocusPage })));
|
||||
const FocusPage = lazyNamed(() => import('../pages/FocusPage'), 'FocusPage');
|
||||
|
||||
export function RootLayout() {
|
||||
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
|
||||
@@ -15,27 +15,17 @@ 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} onEnterFocus={() => setIsFocusMode(true)} hidden={isFocusMode} />
|
||||
<DashboardPage selectedFeedId={selectedFeedId} hidden={isFocusMode} />
|
||||
</div>
|
||||
|
||||
{isFocusMode && (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { lazy, type ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* React.lazy for a named export. After a deploy, chunks of the previous build
|
||||
* no longer exist on the server: reload once to pick up the new version
|
||||
* instead of showing a blank screen.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function lazyNamed<T extends ComponentType<any>>(load: () => Promise<Record<string, unknown>>, name: string) {
|
||||
return lazy(async () => {
|
||||
try {
|
||||
const mod = await load();
|
||||
sessionStorage.removeItem('chunk-reload');
|
||||
return { default: mod[name] as T };
|
||||
} catch (err) {
|
||||
if (!sessionStorage.getItem('chunk-reload')) {
|
||||
sessionStorage.setItem('chunk-reload', '1');
|
||||
window.location.reload();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
+15
-3
@@ -14,13 +14,25 @@ import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { useUpdate } from './stores/updateStore'
|
||||
|
||||
// Prompt-based updates: a new version never reloads the page mid-read.
|
||||
const updateSW = registerSW({
|
||||
const reload = () => window.location.reload()
|
||||
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
useUpdate.getState().setAvailable(() => updateSW(true))
|
||||
useUpdate.getState().setAvailable(reload)
|
||||
},
|
||||
})
|
||||
|
||||
// The service worker activates new versions right away; when it takes over a
|
||||
// page that was already controlled, offer a reload instead of forcing one
|
||||
// under the reader.
|
||||
if ('serviceWorker' in navigator) {
|
||||
const hadController = !!navigator.serviceWorker.controller
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (hadController) useUpdate.getState().setAvailable(reload)
|
||||
})
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
+68
-124
@@ -5,7 +5,6 @@ import { articlesApi, cursorAfter, type Article } from '../api/articles';
|
||||
import { feedsApi } from '../api/feeds';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { Reader } from '../components/reader/Reader';
|
||||
import { SearchBox } from '../components/SearchBox';
|
||||
import { ShortcutsHelp } from '../components/ShortcutsHelp';
|
||||
import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions';
|
||||
import { useLive } from '../hooks/useWebsocket';
|
||||
@@ -16,7 +15,6 @@ const PAGE_SIZE = 30;
|
||||
|
||||
interface DashboardPageProps {
|
||||
selectedFeedId: string | null;
|
||||
onEnterFocus: () => void;
|
||||
/** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
@@ -26,14 +24,12 @@ function isTypingTarget(t: EventTarget | null) {
|
||||
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
||||
}
|
||||
|
||||
export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: DashboardPageProps) {
|
||||
export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [query, setQuery] = useState('');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
|
||||
const unreadPref = usePrefs((s) => s.unreadOnly);
|
||||
@@ -44,27 +40,21 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
|
||||
|
||||
const favorites = selectedFeedId === 'favorites';
|
||||
const feedId = favorites ? null : selectedFeedId;
|
||||
const searching = query.length > 0;
|
||||
const unreadOnly = unreadPref && !favorites && !searching;
|
||||
const unreadOnly = unreadPref && !favorites;
|
||||
|
||||
const listKey = useMemo(
|
||||
() => ['articles', { feedId, favorites, unread: unreadOnly, q: query }] as const,
|
||||
[feedId, favorites, unreadOnly, query],
|
||||
() => ['articles', { feedId, favorites, unread: unreadOnly }] as const,
|
||||
[feedId, favorites, unreadOnly],
|
||||
);
|
||||
|
||||
const { data, isPending, isError, refetch, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, isPlaceholderData } =
|
||||
useInfiniteQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: ({ pageParam }) =>
|
||||
searching
|
||||
? articlesApi.search(query, PAGE_SIZE, Number(pageParam) || 0)
|
||||
: articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }),
|
||||
articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }),
|
||||
initialPageParam: '' as string,
|
||||
getNextPageParam: (last, all) => {
|
||||
if (last.length < PAGE_SIZE) return undefined;
|
||||
return searching ? String(all.length * PAGE_SIZE) : cursorAfter(last[last.length - 1]);
|
||||
},
|
||||
// Keep showing the previous list while a new filter/search loads.
|
||||
getNextPageParam: (last) => (last.length < PAGE_SIZE ? undefined : cursorAfter(last[last.length - 1])),
|
||||
// Keep showing the previous list while another feed or filter loads.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
@@ -76,7 +66,6 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
|
||||
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 feedTitle = feedId ? feeds?.find((f) => f.id === feedId)?.title : undefined;
|
||||
|
||||
// ---------- Reader state lives in the URL (?a=<id>) so Back closes it ----------
|
||||
const openId = params.get('a');
|
||||
@@ -214,9 +203,6 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
|
||||
case 'v':
|
||||
if (current?.url) window.open(current.url, '_blank', 'noopener,noreferrer');
|
||||
break;
|
||||
case '/':
|
||||
searchRef.current?.focus();
|
||||
break;
|
||||
case 'u':
|
||||
setUnreadOnly(!unreadPref);
|
||||
break;
|
||||
@@ -235,108 +221,70 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]);
|
||||
|
||||
const heading = searching
|
||||
? `« ${query} »`
|
||||
: favorites
|
||||
? 'Mes favoris'
|
||||
: feedTitle ?? 'La Une';
|
||||
|
||||
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">
|
||||
{/* Masthead */}
|
||||
<header className="mb-8 border-b border-paper-muted/15 pb-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-5">
|
||||
<div className="min-w-0 flex-1 basis-72">
|
||||
<p className="eyebrow mb-2">{searching ? 'Recherche' : 'Édition du jour'}</p>
|
||||
<h1 className="text-4xl md:text-6xl font-serif italic text-paper-white tracking-tight text-balance break-words">
|
||||
{heading}
|
||||
</h1>
|
||||
<p className="text-paper-muted text-sm mt-3 font-reading" aria-live="polite">
|
||||
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
{!searching && !favorites && (
|
||||
<span className="text-nature font-medium"> · {unreadTotal} non lu{unreadTotal > 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<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 flex-wrap items-center gap-2 sm:gap-3 w-full sm:w-auto">
|
||||
<div className="w-full sm:w-auto order-last sm:order-none">
|
||||
<SearchBox ref={searchRef} onSearch={setQuery} />
|
||||
<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>
|
||||
{!favorites && !searching && (
|
||||
<div className="segmented" role="group" aria-label="Filtrer les articles">
|
||||
<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={() => refreshMutation.mutate()}
|
||||
disabled={refreshMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Marquer ${unreadTotal} article${unreadTotal > 1 ? 's' : ''} comme lu${unreadTotal > 1 ? 's' : ''} ?`)) {
|
||||
markAllMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={markAllMutation.isPending}
|
||||
className="icon-btn"
|
||||
title="Actualiser les flux (r)"
|
||||
aria-label="Actualiser les flux"
|
||||
title="Tout marquer comme lu"
|
||||
aria-label="Tout marquer comme lu"
|
||||
>
|
||||
<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 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>
|
||||
{!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>
|
||||
)}
|
||||
<button onClick={() => setShowHelp(true)} className="icon-btn hidden md:inline-flex" title="Raccourcis clavier (?)" aria-label="Raccourcis clavier">
|
||||
<span className="font-semibold">?</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{/* New articles announcement (never inserted under the reader's eyes) */}
|
||||
{newCount > 0 && !searching && (
|
||||
<div className="sticky top-3 z-30 flex justify-center -mt-2 mb-6 pointer-events-none">
|
||||
<button onClick={showNew} className="btn-primary pointer-events-auto shadow-lg animate-rise">
|
||||
{newCount} nouvel{newCount > 1 ? 's' : ''} article{newCount > 1 ? 's' : ''} · Afficher
|
||||
{/* 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' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focus CTA */}
|
||||
{!feedId && !favorites && !searching && unreadTotal > 0 && (
|
||||
<button
|
||||
onClick={onEnterFocus}
|
||||
className="group w-full mb-8 relative overflow-hidden rounded-2xl bg-nature text-on-nature flex items-center justify-between gap-4 p-5 sm:p-6 text-left transition-transform hover:scale-[1.003]"
|
||||
>
|
||||
<span className="relative z-10">
|
||||
<span className="text-[11px] uppercase tracking-[0.3em] font-bold opacity-85">Mode Focus</span>
|
||||
<span className="block text-xl sm:text-2xl font-serif italic mt-1">Votre session de lecture</span>
|
||||
<span className="block opacity-90 text-sm mt-1">{unreadTotal} articles non lus à trier d'un geste.</span>
|
||||
</span>
|
||||
<span className="relative z-10 shrink-0 inline-flex items-center gap-2 rounded-full bg-carbon-light text-nature font-bold text-xs uppercase tracking-[0.16em] px-5 min-h-11">
|
||||
Lancer
|
||||
<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="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{isPending ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3" aria-label="Chargement des articles" role="status">
|
||||
<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" />
|
||||
@@ -350,7 +298,7 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
|
||||
</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 italic text-xl">
|
||||
<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>
|
||||
@@ -358,7 +306,7 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
|
||||
) : articles.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
className={`grid gap-6 sm:grid-cols-2 xl:grid-cols-3 transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}
|
||||
className={`article-grid transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{articles.map((article, i) => (
|
||||
<ArticleCard
|
||||
@@ -378,7 +326,7 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
|
||||
) : !hasNextPage ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px w-16 bg-nature/25" />
|
||||
<p className="eyebrow text-paper-muted">Fin de l'édition</p>
|
||||
<p className="text-sm text-paper-muted">Fin de la liste</p>
|
||||
<span className="h-px w-16 bg-nature/25" />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -386,25 +334,21 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
|
||||
</>
|
||||
) : (
|
||||
<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 italic text-2xl">
|
||||
{searching
|
||||
? `Aucun article pour « ${query} ».`
|
||||
: favorites
|
||||
? 'Aucun favori pour le moment.'
|
||||
: unreadOnly && (feeds?.length ?? 0) > 0
|
||||
? 'Vous êtes à jour 🌿'
|
||||
: 'Votre bibliothèque est vide.'}
|
||||
<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">
|
||||
{searching
|
||||
? 'Essayez d’autres mots-clés.'
|
||||
: favorites
|
||||
? 'Appuyez sur l’étoile d’un article pour le garder ici.'
|
||||
: unreadOnly && (feeds?.length ?? 0) > 0
|
||||
? 'Tous les articles sont lus.'
|
||||
: 'Ajoutez un flux avec le bouton +.'}
|
||||
{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 && !searching && (feeds?.length ?? 0) > 0 && (
|
||||
{unreadOnly && (feeds?.length ?? 0) > 0 && (
|
||||
<button onClick={() => setUnreadOnly(false)} className="btn-secondary mt-2">Voir tous les articles</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+9
-9
@@ -35,6 +35,12 @@ export default defineConfig({
|
||||
globPatterns: ['**/*.{js,css,html,svg}', '**/*-latin-wght-*.woff2', '**/*-latin-[47]00-*.woff2'],
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/health/],
|
||||
cleanupOutdatedCaches: true,
|
||||
// Activate new versions immediately. A waiting worker would keep
|
||||
// serving a stale app shell to clients that never send SKIP_WAITING
|
||||
// (e.g. tabs still running a previous build), so the old UI would talk
|
||||
// to the new API. The app shows a reload toast instead of reloading.
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Opened articles stay readable offline.
|
||||
@@ -61,15 +67,9 @@ export default defineConfig({
|
||||
cacheableResponse: { statuses: [200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'image',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'images',
|
||||
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 60 * 24 * 30, purgeOnQuotaError: true },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// No image route: feed images are cross-origin, and a fetch() from
|
||||
// the worker is bound by the CSP's connect-src 'self', so it would
|
||||
// fail every cover. The browser's HTTP cache handles them instead.
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'font',
|
||||
handler: 'CacheFirst',
|
||||
|
||||
Reference in new issue
Block a user