Author SHA1 Message Date
Claude 387e594221 fix(web): design review pass — login contrast, focus labels, soft placeholders
- Login: the brand-panel headline inherited the ink heading colour and was
  near-invisible on the dark green; it is white again.
- Focus mode: sentence-case labels instead of spaced capitals, like the
  rest of the app.
- Article placeholder covers (grid and focus) use a soft green tint rather
  than a saturated block, which was glaring in dark mode.
- Reader header: "date · 3 min" fits on phones ("de lecture" from sm up).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsXUyMEGFFGVRcHNVfcBLh
2026-10-09 08:11:32 +00:00
Claude 081420ceb8 fix(web): show feed images again, widen the grid, soften covers
- Drop the service worker's image cache: its cross-origin fetch() is bound
  by the CSP's connect-src 'self', so every cover failed and fell back to
  the green placeholder (in the grid and the reader).
- Grid fills the width with as many ~20rem columns as fit (max 2400px),
  now that the sidebar is gone.
- Covers fade in once loaded and get a light bottom shade.
- Placeholder cover shows a centered initial instead of the feed name
  that overlapped the feed chip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsXUyMEGFFGVRcHNVfcBLh
2026-10-09 07:21:02 +00:00
LogiFlow 23563219d2 Merge pull request #6 from R0m1k3/feat/feed-selector-title
Le titre devient le sélecteur de flux
2026-10-09 08:44:14 +02:00
Antigravity AgentandClaude Opus 5.5 3e740541e5 feat(web): make the page title the feed selector
The row of feed chips is gone. The title in the top bar ("Tous les
articles ▾") now says what is shown and opens the feed list: all articles,
favorites, each feed with its unread count, add a feed, manage feeds.
Desktop shows an anchored menu, phones a bottom sheet; arrow keys, Escape
and focus return are supported.

The dashboard keeps a single quiet toolbar (unread count, Non lus/Tous,
refresh, mark all read). Theme, OPML export, keyboard shortcuts and logout
live in the ⋯ menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:43:23 +02:00
LogiFlow 96fe965e9e Merge pull request #5 from R0m1k3/feat/no-sidebar
Suppression de la barre latérale
2026-10-09 08:30:27 +02:00
Antigravity AgentandClaude Opus 5.5 8bc74b4f52 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>
2026-10-09 08:29:38 +02:00
LogiFlow 8277c28f68 Merge pull request #4 from R0m1k3/fix/reader-card-ux
Lecteur en carte, suppression de la recherche, correctif du cache PWA
2026-10-09 08:27:19 +02:00
13 changed files with 529 additions and 354 deletions

No files matched your search

+8 -3
View File
@@ -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,12 +82,15 @@ 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>
+2 -2
View File
@@ -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>
+125
View File
@@ -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>
);
}
-67
View File
@@ -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)} />}
</>
);
}
-231
View File
@@ -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)} />}
</>
);
}
+357
View File
@@ -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)} />}
</>
);
}
+6 -6
View File
@@ -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>
)}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -255,7 +255,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
<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)} de lecture
<time dateTime={date}>{formatLong(date)}</time> · {readingTimeLabel(listArticle.reading_time)}<span className="hidden sm:inline"> de lecture</span>
</p>
</div>
<button
+7
View File
@@ -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;
+6 -17
View File
@@ -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>
+13 -17
View File
@@ -66,7 +66,6 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
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');
@@ -222,22 +221,19 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
return () => window.removeEventListener('keydown', onKey);
}, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]);
const heading = favorites ? 'Favoris' : (feedTitle ?? 'Tous les articles');
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">
<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>
{!favorites && (
<p className="text-paper-muted text-sm mt-1.5" aria-live="polite">
{unreadTotal === 0
? 'Tout est lu'
: `${unreadTotal} article${unreadTotal > 1 ? 's' : ''} non lu${unreadTotal > 1 ? 's' : ''}`}
</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 items-center gap-2">
{!favorites && (
@@ -275,7 +271,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
</button>
)}
</div>
</header>
</div>
{/* New articles are announced, never inserted under the reader's eyes */}
{newCount > 0 && (
@@ -288,7 +284,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
{/* Grid */}
{isPending ? (
<div className="grid grid-cols-1 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" />
@@ -310,7 +306,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
) : articles.length > 0 ? (
<>
<div
className={`grid grid-cols-1 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
+3 -9
View File
@@ -67,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',