mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-23 07:08:06 +02:00
refonte complète du WebUI (Éditorial Botanique)
Refonte de toutes les vues en conservant l'identité magazine/vert nature, avec un système de design cohérent et accessible. - index.css : tokens sémantiques, échelle radius/ombres, classes réutilisables (btn-*, chip, surface-card, magazine-content), :focus-visible, prefers-reduced-motion, grain papier affiné - nouveaux composants AuthShell (auth split-screen) et MobileTopBar (navigation mobile, auparavant absente) - framer-motion sur toutes les vues ; AddFeedModal expose l'import OPML ; Sidebar ajoute déconnexion + identité utilisateur - corrections : text-gold cassé -> nature, arithmétique Date -> getTime(), suppression de App.css mort, ProtectedRoute en Tailwind, snapshot du deck Focus conforme aux règles React Frontend : npm run lint + build OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a8b5664c5
commit
10fdfdeccb
19 files changed
+1336
-1922
No files matched your search
+143
-235
@@ -6,34 +6,22 @@ import { feedsApi } from '../api/feeds';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { ReaderView } from '../components/ReaderView';
|
||||
import { MobileReaderView } from '../components/MobileReaderView';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useWebsocket } from '../hooks/useWebsocket';
|
||||
import type { Article } from '../api/articles';
|
||||
|
||||
const breakpointColumnsObj = {
|
||||
default: 3,
|
||||
1100: 2,
|
||||
700: 1
|
||||
};
|
||||
const breakpointColumnsObj = { default: 3, 1100: 2, 700: 1 };
|
||||
const ITEMS_PER_PAGE = 24;
|
||||
|
||||
interface DashboardPageProps {
|
||||
selectedFeedId: string | null;
|
||||
onEnterFocus: () => void;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 24;
|
||||
type ArticlePages = { pages: Article[][] };
|
||||
|
||||
// Helper Hook for Responsive Split
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(max-width: 768px)');
|
||||
const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
media.addEventListener('change', listener);
|
||||
return () => media.removeEventListener('change', listener);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
function timestamp(a: Article): number {
|
||||
return new Date(a.published_at || a.created_at).getTime();
|
||||
}
|
||||
|
||||
export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) {
|
||||
@@ -43,118 +31,86 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const isMobile = useIsMobile();
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedQuery(searchQuery);
|
||||
}, 500);
|
||||
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Real-time sync
|
||||
useWebsocket();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['articles', selectedFeedId, debouncedQuery],
|
||||
const queryKey = ['articles', selectedFeedId, debouncedQuery];
|
||||
|
||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
const options = {
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: pageParam,
|
||||
};
|
||||
|
||||
if (debouncedQuery) {
|
||||
return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam);
|
||||
}
|
||||
|
||||
if (selectedFeedId === 'favorites') {
|
||||
return articlesApi.list({ ...options, favorite: true });
|
||||
}
|
||||
const options = { limit: ITEMS_PER_PAGE, offset: pageParam };
|
||||
if (debouncedQuery) return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam);
|
||||
if (selectedFeedId === 'favorites') return articlesApi.list({ ...options, favorite: true });
|
||||
return articlesApi.list({ ...options, feed_id: selectedFeedId || undefined });
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.length < ITEMS_PER_PAGE) return undefined;
|
||||
return allPages.length * ITEMS_PER_PAGE;
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
lastPage.length < ITEMS_PER_PAGE ? undefined : allPages.length * ITEMS_PER_PAGE,
|
||||
});
|
||||
|
||||
const articles = data?.pages.flat() || [];
|
||||
const sortedArticles = useMemo(() => {
|
||||
return [...articles].sort((a, b) => {
|
||||
// First, sort by read status: unread (false) first
|
||||
if (a.is_read !== b.is_read) {
|
||||
return a.is_read ? 1 : -1;
|
||||
}
|
||||
// If same read status, sort by date descending
|
||||
const dateA = a.published_at || a.created_at;
|
||||
const dateB = b.published_at || b.created_at;
|
||||
return new Date(dateB) - new Date(dateA);
|
||||
const all = data?.pages.flat() ?? [];
|
||||
return [...all].sort((a, b) => {
|
||||
if (a.is_read !== b.is_read) return a.is_read ? 1 : -1;
|
||||
return timestamp(b) - timestamp(a);
|
||||
});
|
||||
}, [articles]);
|
||||
}, [data]);
|
||||
|
||||
// Intersection Observer for Infinite Scroll
|
||||
const unreadCount = useMemo(() => sortedArticles.filter((a) => !a.is_read).length, [sortedArticles]);
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (loadMoreRef.current) {
|
||||
observer.observe(loadMoreRef.current);
|
||||
}
|
||||
|
||||
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, fetchNextPage, isFetchingNextPage]);
|
||||
|
||||
// Back-to-top visibility
|
||||
useEffect(() => {
|
||||
const el = mainRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => setShowScrollTop(el.scrollTop > 900);
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: () => feedsApi.refresh(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
||||
});
|
||||
|
||||
const toggleReadMutation = useMutation({
|
||||
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
|
||||
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
|
||||
onMutate: async ({ id, is_read }) => {
|
||||
const queryKey = ['articles', selectedFeedId, debouncedQuery];
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previousData = queryClient.getQueryData(queryKey);
|
||||
|
||||
queryClient.setQueryData(queryKey, (old: any) => {
|
||||
queryClient.setQueryData(queryKey, (old: ArticlePages | undefined) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page: Article[]) =>
|
||||
page.map((article) =>
|
||||
article.id === id ? { ...article, is_read: is_read } : article
|
||||
)
|
||||
),
|
||||
pages: old.pages.map((page) => page.map((a) => (a.id === id ? { ...a, is_read } : a))),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onError: (_err, _newTodo, context) => {
|
||||
const queryKey = ['articles', selectedFeedId, debouncedQuery];
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(queryKey, context.previousData);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
onError: (_e, _v, context) => {
|
||||
if (context?.previousData) queryClient.setQueryData(queryKey, context.previousData);
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
||||
});
|
||||
|
||||
const toggleFavoriteMutation = useMutation({
|
||||
@@ -162,156 +118,116 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
||||
});
|
||||
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Visibility logic for "Back to Top" button
|
||||
useEffect(() => {
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
setShowScrollTop(target.scrollTop > 1000);
|
||||
};
|
||||
const mainElement = mainRef.current;
|
||||
if (mainElement) {
|
||||
mainElement.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
return () => mainElement?.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
mainRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
const heading = debouncedQuery
|
||||
? `Résultats : « ${debouncedQuery} »`
|
||||
: selectedFeedId === 'favorites'
|
||||
? 'Mes favoris'
|
||||
: selectedFeedId
|
||||
? 'Archives du flux'
|
||||
: 'La Une';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-carbon space-y-4">
|
||||
<div className="w-12 h-12 border-t-2 border-nature rounded-full animate-spin"></div>
|
||||
<p className="text-paper-muted text-[10px] uppercase tracking-[0.4em] font-black">Composition de votre édition...</p>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 bg-carbon">
|
||||
<div className="w-11 h-11 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
|
||||
<p className="eyebrow text-paper-muted">Composition de votre édition…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon scroll-smooth relative">
|
||||
{/* Back to Top Button */}
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon relative">
|
||||
{/* Back to top */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-8 right-12 z-40 p-4 rounded-full bg-white border border-nature/10 text-nature shadow-2xl transition-all duration-500 hover:scale-110 active:scale-95 ${showScrollTop ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'}`}
|
||||
onClick={() => mainRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className={`fixed bottom-8 right-8 z-40 icon-btn w-12 h-12 bg-carbon-light shadow-lg transition-all duration-300 ${
|
||||
showScrollTop ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
title="Retour en haut"
|
||||
aria-label="Retour en haut"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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.5} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="max-w-[1400px] mx-auto px-12 py-16">
|
||||
{/* Header */}
|
||||
<header className="mb-20 space-y-12 border-b border-white/5 pb-10">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-nature text-[10px] uppercase tracking-[0.5em] font-black">Édition du jour</p>
|
||||
<h1 className="text-6xl font-serif italic text-paper-white tracking-tighter">
|
||||
{debouncedQuery ? `Résultats : "${debouncedQuery}"` : selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'}
|
||||
<div className="max-w-[1400px] mx-auto px-6 md:px-12 py-10 md:py-14">
|
||||
{/* Masthead */}
|
||||
<header className="mb-12 border-b border-paper-muted/15 pb-8">
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
|
||||
<div>
|
||||
<p className="eyebrow mb-2">Édition du jour</p>
|
||||
<h1 className="text-5xl md:text-6xl font-serif italic text-paper-white tracking-tight text-balance">
|
||||
{heading}
|
||||
</h1>
|
||||
<p className="text-paper-muted text-sm mt-3 font-reading">
|
||||
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
{unreadCount > 0 && <span className="text-nature"> · {unreadCount} non lus</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-6">
|
||||
<div className="flex flex-col items-end text-right">
|
||||
<p className="text-nature text-sm font-serif italic font-bold tracking-wide">
|
||||
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
<p className="text-[10px] text-paper-muted opacity-80 uppercase tracking-[0.25em] font-black mt-1">
|
||||
Volume IX • No. 42
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-paper-muted/40 group-focus-within:text-nature transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="RECHERCHER DANS L'ÉDITION..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-full text-[10px] text-paper-white placeholder-paper-muted/40 font-black tracking-widest focus:outline-none focus:ring-1 focus:ring-nature focus:border-nature transition-all w-48 focus:w-72"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-paper-muted hover:text-paper-white transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refreshMutation.mutate()}
|
||||
disabled={refreshMutation.isPending}
|
||||
className={`text-[10px] uppercase tracking-[0.2em] font-bold px-6 py-2 rounded-full border border-nature/30 text-nature hover:bg-nature hover:text-white hover:border-nature transition-all duration-300 ${refreshMutation.isPending ? 'animate-pulse opacity-50' : 'hover:shadow-lg hover:shadow-nature/20'}`}
|
||||
>
|
||||
{refreshMutation.isPending ? 'Mise à jour' : 'Actualiser'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative group">
|
||||
<span className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-paper-muted/50 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
|
||||
type="search"
|
||||
aria-label="Rechercher dans l'édition"
|
||||
placeholder="Rechercher…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="input-field pl-10 pr-4 py-2.5 rounded-full w-44 focus:w-64 transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refreshMutation.mutate()}
|
||||
disabled={refreshMutation.isPending}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<svg className={`w-3.5 h-3.5 ${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>
|
||||
{refreshMutation.isPending ? 'Mise à jour' : 'Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Focus Mode CTA Widget - Compact Version */}
|
||||
{!selectedFeedId && sortedArticles && sortedArticles.length > 0 && (
|
||||
<div className="mb-12 relative overflow-hidden rounded-xl bg-gradient-to-r from-nature to-nature-light shadow-xl items-center flex flex-col md:flex-row p-6 hover:scale-[1.01] transition-transform cursor-pointer group" onClick={onEnterFocus}>
|
||||
{/* decorative circles */}
|
||||
<div className="absolute -top-12 -right-12 w-48 h-48 bg-white/10 rounded-full blur-2xl group-hover:bg-white/20 transition-colors pointer-events-none"></div>
|
||||
<div className="absolute -bottom-12 -left-12 w-48 h-48 bg-black/10 rounded-full blur-2xl pointer-events-none"></div>
|
||||
|
||||
<div className="flex-1 z-10 text-center md:text-left mb-4 md:mb-0">
|
||||
<div className="flex items-center justify-center md:justify-start space-x-2 mb-1">
|
||||
<span className="bg-white/20 text-white text-[9px] font-bold px-1.5 py-0.5 rounded uppercase tracking-widest backdrop-blur-sm">
|
||||
Nouveauté
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-serif italic text-white mb-1 text-shadow-sm">
|
||||
Votre session de lecture
|
||||
</h2>
|
||||
<p className="text-white/80 font-medium text-xs max-w-lg">
|
||||
{sortedArticles.filter(a => !a.is_read).length} articles non lus. Passez en mode Focus.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="z-10">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEnterFocus(); }}
|
||||
className="bg-white text-nature font-bold text-xs uppercase tracking-widest px-6 py-3 rounded-full shadow-lg hover:bg-gray-50 hover:shadow-xl hover:scale-105 transition-all flex items-center"
|
||||
>
|
||||
Lancer le Mode Focus
|
||||
<svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Focus CTA */}
|
||||
{!selectedFeedId && !debouncedQuery && unreadCount > 0 && (
|
||||
<button
|
||||
onClick={onEnterFocus}
|
||||
className="group w-full mb-10 relative overflow-hidden rounded-2xl bg-nature text-white flex items-center justify-between p-6 text-left transition-transform hover:scale-[1.005]"
|
||||
>
|
||||
<span className="absolute -top-12 -right-12 w-48 h-48 bg-white/10 rounded-full blur-2xl pointer-events-none" />
|
||||
<span className="relative z-10">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-white/70">Mode Focus</span>
|
||||
<span className="block text-2xl font-serif italic mt-1">Votre session de lecture</span>
|
||||
<span className="block text-white/80 text-sm mt-1">{unreadCount} articles non lus à trier d'un geste.</span>
|
||||
</span>
|
||||
<span className="relative z-10 btn-primary bg-white !text-nature group-hover:!bg-white/90">
|
||||
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 */}
|
||||
{sortedArticles.length > 0 ? (
|
||||
<>
|
||||
<Masonry
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
className="flex w-auto -ml-6"
|
||||
columnClassName="pl-6 bg-clip-padding"
|
||||
>
|
||||
{sortedArticles.map((article, index) => (
|
||||
<div key={`${article.id}-${index}`} className="mb-6">
|
||||
<Masonry breakpointCols={breakpointColumnsObj} className="flex w-auto -ml-6" columnClassName="pl-6 bg-clip-padding">
|
||||
{sortedArticles.map((article) => (
|
||||
<div key={article.id} className="mb-6">
|
||||
<ArticleCard
|
||||
article={article}
|
||||
onClick={(article) => {
|
||||
setSelectedArticle(article);
|
||||
if (!article.is_read) {
|
||||
toggleReadMutation.mutate({ id: article.id, is_read: true });
|
||||
}
|
||||
onClick={(a) => {
|
||||
setSelectedArticle(a);
|
||||
if (!a.is_read) toggleReadMutation.mutate({ id: a.id, is_read: true });
|
||||
}}
|
||||
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
|
||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
||||
@@ -320,31 +236,30 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
|
||||
))}
|
||||
</Masonry>
|
||||
|
||||
{/* Infinite Scroll Sentinel */}
|
||||
<div ref={loadMoreRef} className="mt-20 py-10 flex flex-col items-center justify-center space-y-4">
|
||||
<div ref={loadMoreRef} className="mt-16 py-8 flex flex-col items-center gap-3">
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<div className="w-8 h-8 border-t-2 border-nature rounded-full animate-spin"></div>
|
||||
<p className="text-nature text-[8px] uppercase tracking-[0.4em] font-black">Chargement supplémentaire...</p>
|
||||
<div className="w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
|
||||
<p className="eyebrow text-paper-muted/60">Chargement…</p>
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<p className="text-paper-muted/20 text-[8px] uppercase tracking-[0.4em] font-black italic">Défilez pour découvrir la suite</p>
|
||||
<p className="text-paper-muted/40 text-[10px] uppercase tracking-[0.3em] font-bold">Défilez pour la suite</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="h-[1px] w-20 bg-nature/20"></div>
|
||||
<p className="text-paper-muted/40 text-[8px] uppercase tracking-[0.4em] font-black">Fin de votre édition</p>
|
||||
<div className="h-[1px] w-20 bg-nature/20"></div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px w-16 bg-nature/20" />
|
||||
<p className="eyebrow text-paper-muted/50">Fin de l'édition</p>
|
||||
<span className="h-px w-16 bg-nature/20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-40 border-2 border-dashed border-white/5 rounded-3xl">
|
||||
<p className="text-paper-muted font-serif italic text-xl mb-4">
|
||||
{debouncedQuery ? `Aucun article ne correspond à "${debouncedQuery}".` : 'Votre bibliothèque est vide.'}
|
||||
<div className="flex flex-col items-center justify-center py-32 border border-dashed border-paper-muted/20 rounded-3xl text-center px-6">
|
||||
<p className="text-paper-muted font-serif italic text-xl mb-2">
|
||||
{debouncedQuery ? `Aucun article pour « ${debouncedQuery} ».` : 'Votre bibliothèque est vide.'}
|
||||
</p>
|
||||
<p className="text-paper-muted/40 text-[10px] uppercase tracking-widest font-bold">
|
||||
{debouncedQuery ? "Tentez une recherche plus large." : "Ajoutez un flux pour commencer votre lecture."}
|
||||
<p className="eyebrow text-paper-muted/50">
|
||||
{debouncedQuery ? 'Tentez une recherche plus large.' : 'Ajoutez un flux pour commencer.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -357,14 +272,11 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
|
||||
onClose={() => setSelectedArticle(null)}
|
||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
||||
onNext={() => {
|
||||
if (!sortedArticles) return;
|
||||
const idx = sortedArticles.findIndex(a => a.id === selectedArticle.id);
|
||||
const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
|
||||
if (idx < sortedArticles.length - 1) {
|
||||
const nextArticle = sortedArticles[idx + 1];
|
||||
setSelectedArticle(nextArticle);
|
||||
if (!nextArticle.is_read) {
|
||||
toggleReadMutation.mutate({ id: nextArticle.id, is_read: true });
|
||||
}
|
||||
const next = sortedArticles[idx + 1];
|
||||
setSelectedArticle(next);
|
||||
if (!next.is_read) toggleReadMutation.mutate({ id: next.id, is_read: true });
|
||||
} else if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
} else {
|
||||
@@ -372,12 +284,8 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
|
||||
}
|
||||
}}
|
||||
onPrev={() => {
|
||||
if (!sortedArticles) return;
|
||||
const idx = sortedArticles.findIndex(a => a.id === selectedArticle.id);
|
||||
if (idx > 0) {
|
||||
const prevArticle = sortedArticles[idx - 1];
|
||||
setSelectedArticle(prevArticle);
|
||||
}
|
||||
const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
|
||||
if (idx > 0) setSelectedArticle(sortedArticles[idx - 1]);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user