mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-14 10:49:47 +02:00
feat(web): unified reader with reading settings, faster lists, a11y
- New Reader (desktop + mobile): loads full content on open, prefetches the next article, reading progress and time, "Aa" settings (light / sepia / dark / auto theme, serif / sans / Atkinson Hyperlegible font, size, line height, measure), Space-to-next, keyboard shortcuts, focus management, inert background, URL-driven (?a=) so Back closes it. - Dashboard: cursor pagination, unread/all filter, CSS grid (no masonry), no re-sorting under the reader, optimistic cache patches instead of refetch storms, "new articles" banner, error/offline states, j/k/o/m/s/v shortcuts and help dialog. - Cards: keyboard-openable, lazy <img>, memoized, touch-only swipe. - Focus mode: framer drag (drop @use-gesture), Read button, undo restores unread state, arrow keys. - WebSocket: single connection, backoff, cache patching, no console logs. - Contrast fixes (on-nature tokens), 44px targets, lang="fr", reduced motion, self-hosted fonts, theme applied before first paint. - Code splitting + vendor chunk, prompt-based PWA updates, smaller precache, offline cache for opened articles and images, caches cleared on logout; upgrade react-router (CVE), drop unused deps and assets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03e57e4308
commit
360f3cf012
38 files changed
+2557
-1242
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useAuthStore, clearUserData } from '../stores/authStore';
|
||||
import { authApi } from '../api/auth';
|
||||
import { feedsApi } from '../api/feeds';
|
||||
import { articlesApi } from '../api/articles';
|
||||
@@ -23,7 +23,10 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: () => (selectedFeedId ? articlesApi.markAllRead(selectedFeedId) : articlesApi.markAllReadGlobal()),
|
||||
mutationFn: () =>
|
||||
selectedFeedId && selectedFeedId !== 'favorites'
|
||||
? articlesApi.markAllRead(selectedFeedId)
|
||||
: articlesApi.markAllReadGlobal(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
@@ -45,16 +48,19 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () => authApi.logout(),
|
||||
onSuccess: () => { logoutStore(); window.location.href = '/login'; },
|
||||
onSettled: async () => {
|
||||
logoutStore();
|
||||
queryClient.clear();
|
||||
await clearUserData();
|
||||
window.location.href = '/login';
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
const handleDelete = (id: string) => {
|
||||
if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) deleteFeedMutation.mutate(id);
|
||||
};
|
||||
|
||||
const handleRename = (e: React.MouseEvent, id: string, currentTitle: string) => {
|
||||
e.stopPropagation();
|
||||
const handleRename = (id: string, currentTitle: string) => {
|
||||
const newTitle = window.prompt('Renommer le flux :', currentTitle);
|
||||
if (newTitle && newTitle.trim() !== '' && newTitle !== currentTitle) {
|
||||
updateFeedMutation.mutate({ id, title: newTitle.trim() });
|
||||
@@ -73,7 +79,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="w-72 hidden md:flex bg-carbon-light/60 backdrop-blur-xl border-r border-paper-muted/12 h-screen flex-col z-20">
|
||||
<aside className="w-72 hidden md:flex bg-carbon-light/60 border-r border-paper-muted/12 h-dvh flex-col z-20" aria-label="Navigation">
|
||||
{/* Brand + actions */}
|
||||
<div className="p-6 pb-3">
|
||||
<div className="flex items-center justify-between mb-7">
|
||||
@@ -111,7 +117,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
|
||||
<div className="group/item">
|
||||
<button onClick={() => onSelectFeed(null)} className={navItem(selectedFeedId === null && !isFocusMode)}>
|
||||
<button onClick={() => onSelectFeed(null)} className={navItem(selectedFeedId === null && !isFocusMode)} aria-current={selectedFeedId === null && !isFocusMode ? 'page' : undefined}>
|
||||
<span className="text-sm font-medium flex items-center truncate">
|
||||
<span className={dot(selectedFeedId === null)} />Tous les articles
|
||||
</span>
|
||||
@@ -119,7 +125,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
</div>
|
||||
|
||||
<div className="group/item">
|
||||
<button onClick={() => onSelectFeed('favorites')} className={navItem(selectedFeedId === 'favorites')}>
|
||||
<button onClick={() => onSelectFeed('favorites')} className={navItem(selectedFeedId === 'favorites')} aria-current={selectedFeedId === 'favorites' ? 'page' : undefined}>
|
||||
<span className="text-sm font-medium flex items-center truncate">
|
||||
<span className={dot(selectedFeedId === 'favorites')} />Mes favoris
|
||||
</span>
|
||||
@@ -131,8 +137,8 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
onClick={onEnterFocus}
|
||||
className={`w-full flex items-center px-4 py-2.5 rounded-xl text-left transition-all duration-300 ${
|
||||
isFocusMode
|
||||
? 'bg-nature text-white shadow-lg shadow-nature/20'
|
||||
: 'bg-nature/5 text-nature border border-nature/20 hover:bg-nature hover:text-white'
|
||||
? 'bg-nature text-on-nature shadow-lg shadow-nature/20'
|
||||
: 'bg-nature/5 text-nature border border-nature/20 hover:bg-nature hover:text-on-nature'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.18em] flex items-center">
|
||||
@@ -144,52 +150,53 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="px-4 pt-4 pb-2 text-[10px] uppercase tracking-[0.3em] font-bold text-paper-muted/40">Mes flux</h3>
|
||||
<h3 className="px-4 pt-4 pb-2 text-[11px] uppercase tracking-[0.3em] font-bold text-paper-muted">Mes flux</h3>
|
||||
|
||||
{feeds && feeds.length > 0 ? feeds.map((feed) => (
|
||||
<div key={feed.id} className="group/item">
|
||||
<button onClick={() => onSelectFeed(feed.id)} className={navItem(selectedFeedId === feed.id)}>
|
||||
<span className="text-sm font-medium flex items-center truncate">
|
||||
<div key={feed.id} className="group/item relative flex items-center">
|
||||
<button
|
||||
onClick={() => onSelectFeed(feed.id)}
|
||||
className={`${navItem(selectedFeedId === feed.id)} pr-20`}
|
||||
aria-current={selectedFeedId === feed.id ? 'page' : undefined}
|
||||
title={feed.fetch_error ? `Erreur : ${feed.fetch_error}` : feed.title}
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center min-w-0">
|
||||
<span className={dot(selectedFeedId === feed.id)} />
|
||||
<span className="truncate">{feed.title}</span>
|
||||
{feed.fetch_error && <span className="ml-2 text-danger shrink-0" aria-label="Erreur de récupération">!</span>}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{(feed.unread_count ?? 0) > 0 && (
|
||||
<span className="bg-nature/15 text-nature text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center font-bold">
|
||||
{feed.unread_count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => handleRename(e, feed.id, feed.title)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(e as unknown as React.MouseEvent, feed.id, feed.title); }}
|
||||
className="opacity-0 group-hover/item:opacity-60 hover:!opacity-100 p-1 text-nature transition-opacity cursor-pointer"
|
||||
title="Renommer le flux"
|
||||
aria-label="Renommer le flux"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
{(feed.unread_count ?? 0) > 0 && (
|
||||
<span className="bg-nature/15 text-nature text-[11px] px-1.5 py-0.5 rounded-full min-w-[1.5rem] text-center font-bold shrink-0 group-hover/item:opacity-0 group-focus-within/item:opacity-0">
|
||||
{feed.unread_count}
|
||||
<span className="sr-only"> non lus</span>
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => handleDelete(e, feed.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleDelete(e as unknown as React.MouseEvent, feed.id); }}
|
||||
className="opacity-0 group-hover/item:opacity-60 hover:!opacity-100 p-1 text-danger transition-opacity cursor-pointer"
|
||||
title="Supprimer le flux"
|
||||
aria-label="Supprimer le flux"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="absolute right-2 flex items-center opacity-0 group-hover/item:opacity-100 group-focus-within/item:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleRename(feed.id, feed.title)}
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full text-nature hover:bg-nature/10"
|
||||
title="Renommer le flux"
|
||||
aria-label={`Renommer ${feed.title}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(feed.id)}
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full text-danger hover:bg-danger/10"
|
||||
title="Supprimer le flux"
|
||||
aria-label={`Supprimer ${feed.title}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="px-4 py-6 text-xs text-paper-muted/50 italic font-reading">
|
||||
<p className="px-4 py-6 text-sm text-paper-muted italic font-reading">
|
||||
Aucun flux pour l'instant. Ajoutez-en un avec le bouton +.
|
||||
</p>
|
||||
)}
|
||||
@@ -200,7 +207,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-paper-white truncate">{user?.email ?? 'Compte'}</p>
|
||||
<p className="text-[10px] text-paper-muted uppercase tracking-widest font-bold">
|
||||
<p className="text-[11px] text-paper-muted uppercase tracking-widest font-bold">
|
||||
{user?.is_admin ? 'Administrateur' : 'Lecteur'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -218,7 +225,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<AddFeedModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} />
|
||||
{isAddModalOpen && <AddFeedModal onClose={() => setIsAddModalOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user