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:
MichaelandClaude Opus 4.8 committed 2026-06-11 13:21:23 +02:00
1 parent 2a8b5664c5
commit 10fdfdeccb
19 files changed
+1336 -1922

No files matched your search

+143 -235
View File
@@ -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]);
}}
/>
) : (