From b0df24011cfbb9504857c10514a27829100734e6 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 6 Feb 2026 15:20:46 +0100 Subject: [PATCH] feat(web): implement infinite scroll, improve article title readability, and update favicon --- _bmad-output/planning-artifacts/prd.md | 4 +- web/index.html | 25 ++-- web/public/favicon.svg | 5 + web/src/components/ArticleCard.tsx | 4 +- web/src/index.css | 2 +- web/src/pages/DashboardPage.tsx | 154 +++++++++++++++++-------- 6 files changed, 130 insertions(+), 64 deletions(-) create mode 100644 web/public/favicon.svg diff --git a/_bmad-output/planning-artifacts/prd.md b/_bmad-output/planning-artifacts/prd.md index a90444c..fa12f2a 100644 --- a/_bmad-output/planning-artifacts/prd.md +++ b/_bmad-output/planning-artifacts/prd.md @@ -115,8 +115,8 @@ L'objectif est une expérience de lecture complète et fiable pour plusieurs uti ### Reading Experience -* **FR8**: Dashboard unifié "Nouveautés" trié par date. -* **FR9**: Mode "Lecture Plaisir" avec extraction du contenu principal (nettoyage HTML). +* **FR8**: Dashboard unifié "Nouveautés" trié par date avec **scroll infini** pour une navigation fluide. +* **FR9**: Mode "Lecture Plaisir" avec extraction du contenu principal et **typographie optimisée** (suppression de l'italique sur les titres pour un meilleur confort visuel). * **FR10**: Marquage "Lu" manuel ou automatique (scroll/swipe). * **FR11**: Marquage "Tout lu" par flux ou global. * **FR12**: Gestion des Favoris ("Star" / "Read Later"). diff --git a/web/index.html b/web/index.html index d5c6a03..3e3528d 100644 --- a/web/index.html +++ b/web/index.html @@ -1,13 +1,16 @@ - - - - - web - - -
- - - + + + + + + web + + + +
+ + + + \ No newline at end of file diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..473ff32 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,5 @@ + + + F + + diff --git a/web/src/components/ArticleCard.tsx b/web/src/components/ArticleCard.tsx index 76c6746..32ff22e 100644 --- a/web/src/components/ArticleCard.tsx +++ b/web/src/components/ArticleCard.tsx @@ -60,8 +60,8 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite } {/* Content */}
-

{article.title}

diff --git a/web/src/index.css b/web/src/index.css index 7c0dae6..ba0fa62 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -121,7 +121,7 @@ .magazine-content h2, .magazine-content h3 { - @apply text-paper-white font-serif italic mb-6 mt-12; + @apply text-paper-white font-serif mb-6 mt-12; } .magazine-content img { diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 3187bec..9c0fca8 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -1,18 +1,19 @@ -import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useRef } from 'react'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { articlesApi } from '../api/articles'; import { feedsApi } from '../api/feeds'; import { ArticleCard } from '../components/ArticleCard'; import { ReaderView } from '../components/ReaderView'; import { MobileReaderView } from '../components/MobileReaderView'; import { useWebsocket } from '../hooks/useWebsocket'; -import { useEffect } from 'react'; import type { Article } from '../api/articles'; interface DashboardPageProps { selectedFeedId: string | null; } +const ITEMS_PER_PAGE = 24; + // Helper Hook for Responsive Split function useIsMobile() { const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches); @@ -20,7 +21,7 @@ function useIsMobile() { useEffect(() => { const media = window.matchMedia('(max-width: 768px)'); const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches); - media.addEventListener('change', listener); // Modern browsers support this + media.addEventListener('change', listener); return () => media.removeEventListener('change', listener); }, []); @@ -31,25 +32,59 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) { const queryClient = useQueryClient(); const [selectedArticle, setSelectedArticle] = useState
(null); const isMobile = useIsMobile(); + const loadMoreRef = useRef(null); // Real-time sync useWebsocket(); - const { data: articles, isLoading } = useQuery({ + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useInfiniteQuery({ queryKey: ['articles', selectedFeedId], - queryFn: () => { + queryFn: ({ pageParam = 0 }) => { + const options = { + limit: ITEMS_PER_PAGE, + offset: pageParam, + }; if (selectedFeedId === 'favorites') { - return articlesApi.list({ favorite: true }); + return articlesApi.list({ ...options, favorite: true }); } - return articlesApi.list({ feed_id: selectedFeedId || undefined }); + 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; }, }); + const articles = data?.pages.flat() || []; + + // Intersection Observer for Infinite Scroll + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 } + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [hasNextPage, fetchNextPage, isFetchingNextPage]); + const refreshMutation = useMutation({ mutationFn: () => feedsApi.refresh(), onSuccess: () => { - // Optimistic feedback or just wait for WS? - // In case WS fails, we invalid articles queryClient.invalidateQueries({ queryKey: ['articles'] }); }, }); @@ -58,30 +93,29 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) { mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) => is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id), onMutate: async ({ id, is_read }) => { - // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['articles'] }); + const previousData = queryClient.getQueryData(['articles', selectedFeedId]); - // Snapshot previous value - const previousArticles = queryClient.getQueryData(['articles', selectedFeedId]); - - // Optimistically update - queryClient.setQueryData(['articles', selectedFeedId], (old: Article[] | undefined) => { + queryClient.setQueryData(['articles', selectedFeedId], (old: any) => { if (!old) return old; - return old.map((article) => - article.id === id ? { ...article, is_read: is_read } : article - ); + return { + ...old, + pages: old.pages.map((page: Article[]) => + page.map((article) => + article.id === id ? { ...article, is_read: is_read } : article + ) + ), + }; }); - return { previousArticles }; + return { previousData }; }, onError: (_err, _newTodo, context) => { - // Rollback on error - if (context?.previousArticles) { - queryClient.setQueryData(['articles', selectedFeedId], context.previousArticles); + if (context?.previousData) { + queryClient.setQueryData(['articles', selectedFeedId], context.previousData); } }, onSuccess: () => { - // Refetch to be safe (but optimistic update makes it instant) queryClient.invalidateQueries({ queryKey: ['articles'] }); }, }); @@ -94,7 +128,7 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) { if (isLoading) { return (
-
+

Composition de votre édition...

); @@ -107,14 +141,14 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
-

Édition du jour

+

Édition du jour

{selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'}

-

+

{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}

@@ -124,7 +158,7 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) { @@ -133,24 +167,44 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {

{/* Grid */} - {articles && articles.length > 0 ? ( -
- {articles.map((article, index) => ( -
- { - setSelectedArticle(article); - if (!article.is_read) { - toggleReadMutation.mutate({ id: article.id, is_read: true }); - } - }} - onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })} - onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} - /> -
- ))} -
+ {articles.length > 0 ? ( + <> +
+ {articles.map((article, index) => ( +
+ { + setSelectedArticle(article); + if (!article.is_read) { + toggleReadMutation.mutate({ id: article.id, is_read: true }); + } + }} + onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })} + onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} + /> +
+ ))} +
+ + {/* Infinite Scroll Sentinel */} +
+ {isFetchingNextPage ? ( + <> +
+

Chargement d'articles supplémentaires...

+ + ) : hasNextPage ? ( +

Défilez pour découvrir la suite

+ ) : ( +
+
+

Fin de votre édition

+
+
+ )} +
+ ) : (

Votre bibliothèque est vide.

@@ -174,9 +228,13 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) { if (!nextArticle.is_read) { toggleReadMutation.mutate({ id: nextArticle.id, is_read: true }); } + } else if (hasNextPage) { + fetchNextPage().then(() => { + // Logic to select next after fetch could be here, + // but usually we wait for state update. + }); } else { - // End of list feedback? - setSelectedArticle(null); // Close for now or loop? Closing feels right for "Done". + setSelectedArticle(null); } }} onPrev={() => {