import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { ShortcutsHelp } from '../components/ShortcutsHelp'; import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions'; import { useLive } from '../hooks/useWebsocket'; import { prefersReducedMotion } from '../hooks/useIsMobile'; import { usePrefs } from '../stores/prefsStore'; const PAGE_SIZE = 30; interface DashboardPageProps { selectedFeedId: string | null; /** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */ hidden?: boolean; } function isTypingTarget(t: EventTarget | null) { const el = t as HTMLElement | null; return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); } export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) { const qc = useQueryClient(); const [params, setParams] = useSearchParams(); const [showHelp, setShowHelp] = useState(false); const mainRef = useRef(null); const loadMoreRef = useRef(null); const [showScrollTop, setShowScrollTop] = useState(false); const unreadPref = usePrefs((s) => s.unreadOnly); const setUnreadOnly = usePrefs((s) => s.setUnreadOnly); const newCount = useLive((s) => s.newCount); const clearNew = useLive((s) => s.clearNew); const { setRead, toggleRead, toggleFavorite } = useArticleActions(); const favorites = selectedFeedId === 'favorites'; const feedId = favorites ? null : selectedFeedId; const unreadOnly = unreadPref && !favorites; const listKey = useMemo( () => ['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 }) => articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }), initialPageParam: '' as string, 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, }); const articles = useMemo(() => data?.pages.flat() ?? [], [data]); const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() }); const unreadTotal = useMemo(() => { if (!feeds) return 0; 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]); // ---------- Reader state lives in the URL (?a=) so Back closes it ---------- const openId = params.get('a'); const openIndex = openId ? articles.findIndex((a) => a.id === openId) : -1; const deepLinked = useQuery({ queryKey: ['article', openId], queryFn: () => articlesApi.get(openId!), enabled: !!openId && openIndex === -1 && !findCachedArticle(qc, openId), }); const openArticle: Article | undefined = openIndex >= 0 ? articles[openIndex] : openId ? (findCachedArticle(qc, openId) ?? deepLinked.data) : undefined; const openReader = useCallback( (a: Article, replace = false) => { if (!a.is_read) setRead(a, true); setParams( (p) => { const n = new URLSearchParams(p); n.set('a', a.id); return n; }, { replace, state: { reader: true } }, ); }, [setParams, setRead], ); const closeReader = useCallback(() => { if ((window.history.state?.usr as { reader?: boolean } | undefined)?.reader) { window.history.back(); } else { setParams((p) => { const n = new URLSearchParams(p); n.delete('a'); return n; }, { replace: true }); } }, [setParams]); const goNext = useCallback(async () => { if (openIndex < 0) return; if (openIndex + 1 < articles.length) { openReader(articles[openIndex + 1], true); } else if (hasNextPage) { const res = await fetchNextPage(); const all = res.data?.pages.flat() ?? []; if (all[openIndex + 1]) openReader(all[openIndex + 1], true); } else { closeReader(); } }, [openIndex, articles, hasNextPage, fetchNextPage, openReader, closeReader]); const goPrev = useCallback(() => { if (openIndex > 0) openReader(articles[openIndex - 1], true); }, [openIndex, articles, openReader]); // ---------- Infinite scroll (prefetch well before the end) ---------- useEffect(() => { const target = loadMoreRef.current; if (!target) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); }, { root: mainRef.current, rootMargin: '0px 0px 1200px 0px' }, ); observer.observe(target); return () => observer.disconnect(); }, [hasNextPage, fetchNextPage, isFetchingNextPage]); useEffect(() => { const el = mainRef.current; if (!el) return; const onScroll = () => setShowScrollTop(el.scrollTop > 900); el.addEventListener('scroll', onScroll, { passive: true }); return () => el.removeEventListener('scroll', onScroll); }, []); const scrollTop = () => mainRef.current?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); const refreshMutation = useMutation({ mutationFn: () => feedsApi.refresh() }); const markAllMutation = useMutation({ mutationFn: () => (feedId ? articlesApi.markAllRead(feedId) : articlesApi.markAllReadGlobal()), onSuccess: () => { qc.invalidateQueries({ queryKey: ['articles'] }); qc.invalidateQueries({ queryKey: ['feeds'] }); }, }); const showNew = () => { clearNew(); qc.invalidateQueries({ queryKey: ['articles'] }); scrollTop(); }; // ---------- Keyboard navigation on the list ---------- useEffect(() => { if (hidden || openId) return; const cardAt = (i: number) => mainRef.current?.querySelector(`[data-index="${i}"] h2 button`); const currentIndex = () => { const el = (document.activeElement as HTMLElement | null)?.closest('[data-index]'); return el ? Number(el.dataset.index) : -1; }; const onKey = (e: KeyboardEvent) => { if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return; const i = currentIndex(); const current = i >= 0 ? articles[i] : undefined; switch (e.key) { case 'j': case 'n': { const next = Math.min(articles.length - 1, i + 1); cardAt(next)?.focus(); cardAt(next)?.scrollIntoView({ block: 'nearest' }); if (next >= articles.length - 3 && hasNextPage) fetchNextPage(); break; } case 'k': case 'p': { const prev = Math.max(0, i - 1); cardAt(prev)?.focus(); cardAt(prev)?.scrollIntoView({ block: 'nearest' }); break; } case 'o': if (current) openReader(current); break; case 'm': if (current) toggleRead(current); break; case 's': case 'f': if (current) toggleFavorite(current); break; case 'v': if (current?.url) window.open(current.url, '_blank', 'noopener,noreferrer'); break; case 'u': setUnreadOnly(!unreadPref); break; case 'r': refreshMutation.mutate(); break; case '?': setShowHelp(true); break; default: return; } e.preventDefault(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]); return (
{/* The page title is the feed selector in the top bar. */}

{favorites ? `${articles.length}${hasNextPage ? '+' : ''} favori${articles.length > 1 ? 's' : ''}` : unreadTotal === 0 ? 'Tout est lu' : `${unreadTotal} article${unreadTotal > 1 ? 's' : ''} non lu${unreadTotal > 1 ? 's' : ''}`}

{!favorites && (
)} {!favorites && unreadTotal > 0 && ( )}
{/* New articles are announced, never inserted under the reader's eyes */} {newCount > 0 && (
)} {/* Grid */} {isPending ? (
{Array.from({ length: 6 }, (_, i) => (
))}
) : isError && articles.length === 0 ? (

{navigator.onLine ? 'Impossible de charger vos articles.' : 'Vous êtes hors ligne.'}

) : articles.length > 0 ? ( <>
{articles.map((article, i) => ( ))}
{isFetchingNextPage ? (
) : !hasNextPage ? (

Fin de la liste

) : null}
) : (

{favorites ? 'Aucun favori' : unreadOnly && (feeds?.length ?? 0) > 0 ? 'Tout est lu' : 'Aucun article'}

{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 +.'}

{unreadOnly && (feeds?.length ?? 0) > 0 && ( )}
)}
{/* Back to top */} {openArticle && !hidden && ( = 0 ? articles[openIndex + 1] : undefined} hasPrev={openIndex > 0} onClose={closeReader} onNext={openIndex >= 0 && (openIndex + 1 < articles.length || hasNextPage) ? goNext : undefined} onPrev={goPrev} /> )} {showHelp && setShowHelp(false)} />}
); }