mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-19 13:18:06 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd73b3d09d | ||
|
|
387e594221 | ||
|
|
081420ceb8 | ||
|
|
23563219d2 | ||
|
|
3e740541e5 | ||
|
|
96fe965e9e |
No files matched your search
@@ -15,9 +15,10 @@ interface ArticleCardProps {
|
|||||||
|
|
||||||
/** Initial-letter cover used when a feed provides no image (no third-party fallback). */
|
/** Initial-letter cover used when a feed provides no image (no third-party fallback). */
|
||||||
function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
||||||
|
const initial = (feed || title).trim().charAt(0).toUpperCase();
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full bg-gradient-to-br from-nature/90 to-nature-dark flex items-end p-5">
|
<div className="w-full h-full bg-nature/12 flex items-center justify-center" aria-hidden="true">
|
||||||
<span className="font-serif italic text-on-nature/90 text-lg leading-snug line-clamp-3">{feed || title}</span>
|
<span className="font-serif italic text-nature/45 text-7xl leading-none select-none">{initial}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
|||||||
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
|
const [imgLoaded, setImgLoaded] = useState(false);
|
||||||
|
|
||||||
// Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text
|
// Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text
|
||||||
// selection) never trigger it.
|
// selection) never trigger it.
|
||||||
@@ -80,12 +82,15 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
|
|||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
width={640}
|
width={640}
|
||||||
height={360}
|
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)}
|
onError={() => setImgFailed(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CoverFallback title={article.title} feed={article.feed_title} />
|
<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">
|
<span className="absolute bottom-3 left-3 chip bg-carbon-light/95 shadow-sm max-w-[75%] truncate">
|
||||||
{article.feed_title || 'Journal'}
|
{article.feed_title || 'Journal'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps
|
|||||||
transition={{ duration: 0.7, delay: 0.1 }}
|
transition={{ duration: 0.7, delay: 0.1 }}
|
||||||
className="relative z-10 space-y-6"
|
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.
|
Le calme dans le chaos de l'information.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-white/70 font-reading text-lg max-w-sm leading-relaxed">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</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
|
FlowReader · Édition 2026
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+296
-107
@@ -3,9 +3,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { feedsApi } from '../api/feeds';
|
import { feedsApi } from '../api/feeds';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
import { useAuthStore, clearUserData } from '../stores/authStore';
|
import { useAuthStore, clearUserData } from '../stores/authStore';
|
||||||
|
import { usePrefs } from '../stores/prefsStore';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { AddFeedModal } from './AddFeedModal';
|
import { AddFeedModal } from './AddFeedModal';
|
||||||
import { ManageFeedsModal } from './ManageFeedsModal';
|
import { ManageFeedsModal } from './ManageFeedsModal';
|
||||||
import { ThemeToggle } from './ThemeToggle';
|
import { ShortcutsHelp } from './ShortcutsHelp';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
onSelectFeed: (feedId: string | null) => void;
|
onSelectFeed: (feedId: string | null) => void;
|
||||||
@@ -13,15 +15,76 @@ interface TopBarProps {
|
|||||||
onEnterFocus: () => void;
|
onEnterFocus: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single navigation bar for every screen size (no sidebar). */
|
interface ScopeItem {
|
||||||
|
id: string | null;
|
||||||
|
label: string;
|
||||||
|
unread: number;
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes a popover on outside pointer down or Escape, returning focus to its trigger. */
|
||||||
|
function useDismiss(open: boolean, close: () => void, refs: React.RefObject<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) {
|
export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarProps) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [scopeOpen, setScopeOpen] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [manageOpen, setManageOpen] = useState(false);
|
const [manageOpen, setManageOpen] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
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 user = useAuthStore((s) => s.user);
|
||||||
const logoutStore = useAuthStore((s) => s.logout);
|
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 { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
|
||||||
|
|
||||||
const logout = useMutation({
|
const logout = useMutation({
|
||||||
@@ -34,126 +97,251 @@ export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarPro
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close the account menu on outside click / Escape.
|
const closeScope = () => {
|
||||||
|
setScopeOpen(false);
|
||||||
|
scopeBtn.current?.focus();
|
||||||
|
};
|
||||||
|
const closeMenu = () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
menuBtn.current?.focus();
|
||||||
|
};
|
||||||
|
useDismiss(scopeOpen, closeScope, [scopeBtn, scopePanel]);
|
||||||
|
useDismiss(menuOpen, closeMenu, [menuBtn, menuPanel]);
|
||||||
|
|
||||||
|
// Focus the selected entry when the feed menu opens.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!scopeOpen) return;
|
||||||
const onDown = (e: MouseEvent) => {
|
const el = scopePanel.current?.querySelector<HTMLElement>('[aria-checked="true"]') ?? scopePanel.current?.querySelector<HTMLElement>('[role="menuitemradio"]');
|
||||||
if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
el?.focus();
|
||||||
};
|
}, [scopeOpen]);
|
||||||
const onKey = (e: KeyboardEvent) => {
|
useEffect(() => {
|
||||||
if (e.key === 'Escape') setMenuOpen(false);
|
if (menuOpen) menuPanel.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', onDown);
|
|
||||||
window.addEventListener('keydown', onKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', onDown);
|
|
||||||
window.removeEventListener('keydown', onKey);
|
|
||||||
};
|
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
const totalUnread = (feeds ?? []).reduce((n, f) => n + (f.unread_count ?? 0), 0);
|
const totalUnread = (feeds ?? []).reduce((n, f) => n + (f.unread_count ?? 0), 0);
|
||||||
const items = [
|
const main: ScopeItem[] = [
|
||||||
{ id: null as string | null, label: 'Tous', unread: totalUnread },
|
{ id: null, label: 'Tous les articles', unread: totalUnread },
|
||||||
{ id: 'favorites' as string | null, label: 'Favoris', unread: 0 },
|
{ id: 'favorites', label: 'Favoris', unread: 0 },
|
||||||
...(feeds ?? []).map((f) => ({ id: f.id as string | null, label: f.title, unread: f.unread_count ?? 0, error: !!f.fetch_error })),
|
|
||||||
];
|
];
|
||||||
|
const 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header className="sticky top-0 z-30 bg-carbon/95 border-b border-paper-muted/12" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
|
||||||
className="sticky top-0 z-30 bg-carbon-light/95 border-b border-paper-muted/12"
|
<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">
|
||||||
style={{ paddingTop: 'env(safe-area-inset-top)' }}
|
<span className="hidden sm:block text-nature text-lg font-serif shrink-0" aria-hidden="true">
|
||||||
>
|
FlowReader
|
||||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12">
|
</span>
|
||||||
<div className="flex items-center justify-between gap-3 h-14">
|
<span className="hidden sm:block h-6 w-px bg-paper-muted/20" aria-hidden="true" />
|
||||||
<button onClick={() => onSelectFeed(null)} className="text-nature text-xl font-serif shrink-0">
|
|
||||||
FlowReader
|
{/* Title = feed selector */}
|
||||||
</button>
|
<div className="relative min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<h1 className="min-w-0">
|
||||||
<button onClick={onEnterFocus} className="icon-btn" title="Mode Focus : trier les non lus" aria-label="Mode Focus">
|
<button
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
ref={scopeBtn}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
onClick={() => setScopeOpen((o) => !o)}
|
||||||
</svg>
|
aria-haspopup="menu"
|
||||||
</button>
|
aria-expanded={scopeOpen}
|
||||||
<ThemeToggle />
|
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"
|
||||||
<button onClick={() => setAddOpen(true)} className="icon-btn" title="Ajouter un flux" aria-label="Ajouter un flux">
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<span className="font-serif text-xl sm:text-2xl text-paper-white truncate">{current.label}</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<svg
|
||||||
</svg>
|
className={`w-4 h-4 shrink-0 text-paper-muted transition-transform ${scopeOpen ? 'rotate-180' : ''}`}
|
||||||
</button>
|
fill="none"
|
||||||
<div className="relative" ref={menuRef}>
|
viewBox="0 0 24 24"
|
||||||
<button
|
stroke="currentColor"
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
aria-hidden="true"
|
||||||
className="icon-btn"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={menuOpen}
|
|
||||||
aria-label="Compte et réglages"
|
|
||||||
title="Compte et réglages"
|
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M19 9l-7 7-7-7" />
|
||||||
<circle cx="5" cy="12" r="1.8" />
|
</svg>
|
||||||
<circle cx="12" cy="12" r="1.8" />
|
<span className="sr-only">Changer de flux</span>
|
||||||
<circle cx="19" cy="12" r="1.8" />
|
</button>
|
||||||
</svg>
|
</h1>
|
||||||
</button>
|
{scopeOpen && !isMobile && scopeList}
|
||||||
{menuOpen && (
|
|
||||||
<div role="menu" className="absolute right-0 top-12 w-60 surface-card py-1 z-50 animate-fade-in">
|
|
||||||
{user && <p className="px-4 py-2.5 text-xs text-paper-muted truncate border-b border-paper-muted/10">{user.email}</p>}
|
|
||||||
<button
|
|
||||||
role="menuitem"
|
|
||||||
className="w-full text-left px-4 min-h-11 text-sm text-paper-white hover:bg-nature/10"
|
|
||||||
onClick={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
setManageOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Gérer mes flux
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
role="menuitem"
|
|
||||||
href="/api/v1/feeds/export/opml"
|
|
||||||
className="flex items-center w-full px-4 min-h-11 text-sm text-paper-white hover:bg-nature/10"
|
|
||||||
onClick={() => setMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Exporter en OPML
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
role="menuitem"
|
|
||||||
className="w-full text-left px-4 min-h-11 text-sm text-danger hover:bg-danger/10 border-t border-paper-muted/10"
|
|
||||||
onClick={() => logout.mutate()}
|
|
||||||
>
|
|
||||||
Se déconnecter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feeds */}
|
<button onClick={onEnterFocus} className="btn-ghost !px-3 shrink-0" title="Trier les articles non lus un par un">
|
||||||
<nav className="flex gap-2 pb-3 overflow-x-auto no-scrollbar -mx-1 px-1" aria-label="Flux">
|
<svg className="w-4 h-4 text-nature" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
{items.map((item) => {
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
const active = selectedFeedId === item.id;
|
</svg>
|
||||||
return (
|
<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
|
<button
|
||||||
key={item.id ?? 'all'}
|
role="menuitem"
|
||||||
onClick={() => onSelectFeed(item.id)}
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
aria-current={active ? 'page' : undefined}
|
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"
|
||||||
title={'error' in item && item.error ? 'Ce flux ne se met plus à jour' : undefined}
|
|
||||||
className={`shrink-0 inline-flex items-center gap-1.5 min-h-10 px-3.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
|
|
||||||
active ? 'bg-nature text-on-nature' : 'bg-nature/10 text-paper-white/80 hover:text-nature'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{'error' in item && item.error && <span className="w-1.5 h-1.5 rounded-full bg-danger" aria-hidden="true" />}
|
Mode sombre
|
||||||
{item.label}
|
<span
|
||||||
{item.unread > 0 && <span className={active ? 'opacity-80' : 'text-paper-muted'}>{item.unread}</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>
|
||||||
);
|
<button
|
||||||
})}
|
role="menuitem"
|
||||||
</nav>
|
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>
|
</div>
|
||||||
</header>
|
</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)} />}
|
{addOpen && <AddFeedModal onClose={() => setAddOpen(false)} />}
|
||||||
{manageOpen && (
|
{manageOpen && (
|
||||||
<ManageFeedsModal
|
<ManageFeedsModal
|
||||||
@@ -163,6 +351,7 @@ export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarPro
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -24,19 +24,19 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
|
|||||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
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">
|
<div className="w-full h-full bg-nature/12 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>
|
<span className="text-2xl font-serif italic text-nature leading-tight line-clamp-3">{article.feed_title}</span>
|
||||||
</div>
|
</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)}
|
{readingTimeLabel(article.reading_time)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 p-5 sm:p-6 flex flex-col min-h-0">
|
<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">
|
<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 truncate">
|
<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="w-1.5 h-1.5 rounded-full bg-nature shrink-0" aria-hidden="true" />
|
||||||
<span className="truncate">{article.feed_title || 'Journal'}</span>
|
<span className="truncate">{article.feed_title || 'Journal'}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -54,7 +54,7 @@ export function FocusCard({ article, isTop = false }: FocusCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isTop && (
|
{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
|
← Garder · Lu → · Entrée pour lire
|
||||||
</p>
|
</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">
|
<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>
|
<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' : ''}
|
{articles.length - currentIndex} restant{articles.length - currentIndex > 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o
|
|||||||
<div className="flex-1 min-w-0 text-sm">
|
<div className="flex-1 min-w-0 text-sm">
|
||||||
<p className="font-semibold text-nature truncate">{listArticle.feed_title || 'Article'}</p>
|
<p className="font-semibold text-nature truncate">{listArticle.feed_title || 'Article'}</p>
|
||||||
<p className="text-paper-muted truncate">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -113,6 +113,13 @@
|
|||||||
text-wrap: pretty;
|
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. */
|
/* Skip rendering work for off-screen cards in long lists. */
|
||||||
@utility cv-auto {
|
@utility cv-auto {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
|||||||
if (feedId) return feeds.find((f) => f.id === feedId)?.unread_count ?? 0;
|
if (feedId) return feeds.find((f) => f.id === feedId)?.unread_count ?? 0;
|
||||||
return feeds.reduce((n, f) => n + (f.unread_count ?? 0), 0);
|
return feeds.reduce((n, f) => n + (f.unread_count ?? 0), 0);
|
||||||
}, [feeds, feedId]);
|
}, [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 ----------
|
// ---------- Reader state lives in the URL (?a=<id>) so Back closes it ----------
|
||||||
const openId = params.get('a');
|
const openId = params.get('a');
|
||||||
@@ -222,22 +221,19 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]);
|
}, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]);
|
||||||
|
|
||||||
const heading = favorites ? 'Favoris' : (feedTitle ?? 'Tous les articles');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}>
|
<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-6 md:py-8">
|
<div className="max-w-[2400px] mx-auto px-4 sm:px-6 md:px-12 py-6 md:py-8">
|
||||||
<header className="mb-8 flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
|
{/* The page title is the feed selector in the top bar. */}
|
||||||
<div className="min-w-0">
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||||
<h1 className="text-3xl md:text-4xl font-serif text-paper-white tracking-tight break-words">{heading}</h1>
|
<p className="text-paper-muted text-sm" aria-live="polite">
|
||||||
{!favorites && (
|
{favorites
|
||||||
<p className="text-paper-muted text-sm mt-1.5" aria-live="polite">
|
? `${articles.length}${hasNextPage ? '+' : ''} favori${articles.length > 1 ? 's' : ''}`
|
||||||
{unreadTotal === 0
|
: unreadTotal === 0
|
||||||
? 'Tout est lu'
|
? 'Tout est lu'
|
||||||
: `${unreadTotal} article${unreadTotal > 1 ? 's' : ''} non lu${unreadTotal > 1 ? 's' : ''}`}
|
: `${unreadTotal} article${unreadTotal > 1 ? 's' : ''} non lu${unreadTotal > 1 ? 's' : ''}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!favorites && (
|
{!favorites && (
|
||||||
@@ -275,7 +271,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
{/* New articles are announced, never inserted under the reader's eyes */}
|
{/* New articles are announced, never inserted under the reader's eyes */}
|
||||||
{newCount > 0 && (
|
{newCount > 0 && (
|
||||||
@@ -288,7 +284,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
|||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
{isPending ? (
|
{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) => (
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
<div key={i} className="rounded-2xl bg-carbon-light border border-paper-muted/10 overflow-hidden">
|
<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" />
|
<div className="aspect-[16/9] bg-carbon-dark animate-pulse" />
|
||||||
@@ -310,7 +306,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
|
|||||||
) : articles.length > 0 ? (
|
) : articles.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<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) => (
|
{articles.map((article, i) => (
|
||||||
<ArticleCard
|
<ArticleCard
|
||||||
|
|||||||
+3
-9
@@ -67,15 +67,9 @@ export default defineConfig({
|
|||||||
cacheableResponse: { statuses: [200] },
|
cacheableResponse: { statuses: [200] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
// No image route: feed images are cross-origin, and a fetch() from
|
||||||
urlPattern: ({ request }) => request.destination === 'image',
|
// the worker is bound by the CSP's connect-src 'self', so it would
|
||||||
handler: 'StaleWhileRevalidate',
|
// fail every cover. The browser's HTTP cache handles them instead.
|
||||||
options: {
|
|
||||||
cacheName: 'images',
|
|
||||||
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 60 * 24 * 30, purgeOnQuotaError: true },
|
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
urlPattern: ({ request }) => request.destination === 'font',
|
urlPattern: ({ request }) => request.destination === 'font',
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
|
|||||||
Reference in new issue
Block a user