mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(web): implement infinite scroll, improve article title readability, and update favicon
This commit is contained in:
1 parent
bee0d901f7
commit
b0df24011c
6 files changed
+129
-63
No files matched your search
@@ -115,8 +115,8 @@ L'objectif est une expérience de lecture complète et fiable pour plusieurs uti
|
|||||||
|
|
||||||
### Reading Experience
|
### Reading Experience
|
||||||
|
|
||||||
* **FR8**: Dashboard unifié "Nouveautés" trié par date.
|
* **FR8**: Dashboard unifié "Nouveautés" trié par date avec **scroll infini** pour une navigation fluide.
|
||||||
* **FR9**: Mode "Lecture Plaisir" avec extraction du contenu principal (nettoyage HTML).
|
* **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).
|
* **FR10**: Marquage "Lu" manuel ou automatique (scroll/swipe).
|
||||||
* **FR11**: Marquage "Tout lu" par flux ou global.
|
* **FR11**: Marquage "Tout lu" par flux ou global.
|
||||||
* **FR12**: Gestion des Favoris ("Star" / "Read Later").
|
* **FR12**: Gestion des Favoris ("Star" / "Read Later").
|
||||||
|
|||||||
+13
-10
@@ -1,13 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>web</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
</head>
|
<title>web</title>
|
||||||
<body>
|
</head>
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<body>
|
||||||
</body>
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#166534"/>
|
||||||
|
<text x="16" y="23" font-family="Playfair Display, serif" font-size="18" font-weight="900" fill="#F9F7F1" text-anchor="middle">F</text>
|
||||||
|
<path d="M8 26 L24 26" stroke="#F9F7F1" stroke-width="1" opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 336 B |
@@ -60,8 +60,8 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 flex flex-col px-1 gap-4">
|
<div className="flex-1 flex flex-col px-1 gap-4">
|
||||||
<h2 className={`text-xl font-serif italic transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-bold'}`}
|
<h2 className={`text-xl font-serif transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-black'}`}
|
||||||
style={article.is_read ? {} : { textShadow: '0 1px 1px rgba(0,0,0,0.1)' }}
|
style={article.is_read ? {} : { textShadow: '0 1px 1px rgba(0,0,0,0.05)' }}
|
||||||
>
|
>
|
||||||
{article.title}
|
{article.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
+1
-1
@@ -121,7 +121,7 @@
|
|||||||
|
|
||||||
.magazine-content h2,
|
.magazine-content h2,
|
||||||
.magazine-content h3 {
|
.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 {
|
.magazine-content img {
|
||||||
|
|||||||
+106
-48
@@ -1,18 +1,19 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { articlesApi } from '../api/articles';
|
import { articlesApi } from '../api/articles';
|
||||||
import { feedsApi } from '../api/feeds';
|
import { feedsApi } from '../api/feeds';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { ReaderView } from '../components/ReaderView';
|
import { ReaderView } from '../components/ReaderView';
|
||||||
import { MobileReaderView } from '../components/MobileReaderView';
|
import { MobileReaderView } from '../components/MobileReaderView';
|
||||||
import { useWebsocket } from '../hooks/useWebsocket';
|
import { useWebsocket } from '../hooks/useWebsocket';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import type { Article } from '../api/articles';
|
import type { Article } from '../api/articles';
|
||||||
|
|
||||||
interface DashboardPageProps {
|
interface DashboardPageProps {
|
||||||
selectedFeedId: string | null;
|
selectedFeedId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 24;
|
||||||
|
|
||||||
// Helper Hook for Responsive Split
|
// Helper Hook for Responsive Split
|
||||||
function useIsMobile() {
|
function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches);
|
const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches);
|
||||||
@@ -20,7 +21,7 @@ function useIsMobile() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const media = window.matchMedia('(max-width: 768px)');
|
const media = window.matchMedia('(max-width: 768px)');
|
||||||
const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
media.addEventListener('change', listener); // Modern browsers support this
|
media.addEventListener('change', listener);
|
||||||
return () => media.removeEventListener('change', listener);
|
return () => media.removeEventListener('change', listener);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -31,25 +32,59 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Real-time sync
|
// Real-time sync
|
||||||
useWebsocket();
|
useWebsocket();
|
||||||
|
|
||||||
const { data: articles, isLoading } = useQuery({
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: ['articles', selectedFeedId],
|
queryKey: ['articles', selectedFeedId],
|
||||||
queryFn: () => {
|
queryFn: ({ pageParam = 0 }) => {
|
||||||
|
const options = {
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
offset: pageParam,
|
||||||
|
};
|
||||||
if (selectedFeedId === 'favorites') {
|
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({
|
const refreshMutation = useMutation({
|
||||||
mutationFn: () => feedsApi.refresh(),
|
mutationFn: () => feedsApi.refresh(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Optimistic feedback or just wait for WS?
|
|
||||||
// In case WS fails, we invalid articles
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -58,30 +93,29 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
|||||||
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
|
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
|
||||||
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
|
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
|
||||||
onMutate: async ({ id, is_read }) => {
|
onMutate: async ({ id, is_read }) => {
|
||||||
// Cancel outgoing refetches
|
|
||||||
await queryClient.cancelQueries({ queryKey: ['articles'] });
|
await queryClient.cancelQueries({ queryKey: ['articles'] });
|
||||||
|
const previousData = queryClient.getQueryData(['articles', selectedFeedId]);
|
||||||
|
|
||||||
// Snapshot previous value
|
queryClient.setQueryData(['articles', selectedFeedId], (old: any) => {
|
||||||
const previousArticles = queryClient.getQueryData(['articles', selectedFeedId]);
|
|
||||||
|
|
||||||
// Optimistically update
|
|
||||||
queryClient.setQueryData(['articles', selectedFeedId], (old: Article[] | undefined) => {
|
|
||||||
if (!old) return old;
|
if (!old) return old;
|
||||||
return old.map((article) =>
|
return {
|
||||||
article.id === id ? { ...article, is_read: is_read } : article
|
...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) => {
|
onError: (_err, _newTodo, context) => {
|
||||||
// Rollback on error
|
if (context?.previousData) {
|
||||||
if (context?.previousArticles) {
|
queryClient.setQueryData(['articles', selectedFeedId], context.previousData);
|
||||||
queryClient.setQueryData(['articles', selectedFeedId], context.previousArticles);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Refetch to be safe (but optimistic update makes it instant)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -94,7 +128,7 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center bg-carbon space-y-4">
|
<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-gold rounded-full animate-spin"></div>
|
<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>
|
<p className="text-paper-muted text-[10px] uppercase tracking-[0.4em] font-black">Composition de votre édition...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -107,14 +141,14 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
|||||||
<header className="mb-20 space-y-4 border-b border-white/5 pb-10">
|
<header className="mb-20 space-y-4 border-b border-white/5 pb-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-gold text-[10px] uppercase tracking-[0.5em] font-black">Édition du jour</p>
|
<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">
|
<h1 className="text-6xl font-serif italic text-paper-white tracking-tighter">
|
||||||
{selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'}
|
{selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<p className="text-gold text-sm font-serif italic font-bold tracking-wide">
|
<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' })}
|
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-paper-muted opacity-80 uppercase tracking-[0.25em] font-black mt-1">
|
<p className="text-[10px] text-paper-muted opacity-80 uppercase tracking-[0.25em] font-black mt-1">
|
||||||
@@ -124,7 +158,7 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => refreshMutation.mutate()}
|
onClick={() => refreshMutation.mutate()}
|
||||||
disabled={refreshMutation.isPending}
|
disabled={refreshMutation.isPending}
|
||||||
className={`text-[10px] uppercase tracking-[0.2em] font-bold px-6 py-2.5 rounded-full border border-gold/30 text-gold 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'}`}
|
className={`text-[10px] uppercase tracking-[0.2em] font-bold px-6 py-2.5 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...' : 'Mettre à jour'}
|
{refreshMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
|
||||||
</button>
|
</button>
|
||||||
@@ -133,24 +167,44 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
{articles && articles.length > 0 ? (
|
{articles.length > 0 ? (
|
||||||
<div className="magazine-grid">
|
<>
|
||||||
{articles.map((article, index) => (
|
<div className="magazine-grid">
|
||||||
<div key={article.id} className={index % 6 === 0 ? 'md:col-span-2' : ''}>
|
{articles.map((article, index) => (
|
||||||
<ArticleCard
|
<div key={`${article.id}-${index}`} className={index % 6 === 0 ? 'md:col-span-2' : ''}>
|
||||||
article={article}
|
<ArticleCard
|
||||||
onClick={(article) => {
|
article={article}
|
||||||
setSelectedArticle(article);
|
onClick={(article) => {
|
||||||
if (!article.is_read) {
|
setSelectedArticle(article);
|
||||||
toggleReadMutation.mutate({ id: article.id, is_read: true });
|
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)}
|
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
|
||||||
/>
|
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
||||||
</div>
|
/>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infinite Scroll Sentinel */}
|
||||||
|
<div ref={loadMoreRef} className="mt-20 py-10 flex flex-col items-center justify-center space-y-4">
|
||||||
|
{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 d'articles supplémentaires...</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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-40 border-2 border-dashed border-white/5 rounded-3xl">
|
<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">Votre bibliothèque est vide.</p>
|
<p className="text-paper-muted font-serif italic text-xl mb-4">Votre bibliothèque est vide.</p>
|
||||||
@@ -174,9 +228,13 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
|||||||
if (!nextArticle.is_read) {
|
if (!nextArticle.is_read) {
|
||||||
toggleReadMutation.mutate({ id: nextArticle.id, is_read: true });
|
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 {
|
} else {
|
||||||
// End of list feedback?
|
setSelectedArticle(null);
|
||||||
setSelectedArticle(null); // Close for now or loop? Closing feels right for "Done".
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPrev={() => {
|
onPrev={() => {
|
||||||
|
|||||||
Reference in new issue
Block a user