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:
Antigravity AgentandClaude Opus 5.5 committed 2026-10-09 07:34:09 +02:00
1 parent 03e57e4308
commit 360f3cf012
38 files changed
+2557 -1242

No files matched your search

+6 -5
View File
@@ -1,15 +1,16 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="theme-color" content="#166534" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#F7F4EC" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description"
content="FlowReader - Votre édition quotidienne pour une lecture plaisir et sans distractions." />
<meta name="color-scheme" content="light dark" />
<title>FlowReader | La Une</title>
<script src="/theme-init.js"></script>
</head>
<body>
@@ -17,4 +18,4 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
+54 -52
View File
@@ -8,15 +8,16 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/lora": "^5.3.0",
"@fontsource-variable/playfair-display": "^5.3.0",
"@fontsource/atkinson-hyperlegible": "^5.3.0",
"@tanstack/react-query": "^5.90.20",
"@use-gesture/react": "^10.3.1",
"framer-motion": "^12.34.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-masonry-css": "^1.0.16",
"react-router-dom": "^7.13.0",
"react-router-dom": "^7.18.4",
"react-swipeable": "^7.0.2",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.11"
},
"devDependencies": {
@@ -2247,6 +2248,42 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/lora": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/lora/-/lora-5.3.0.tgz",
"integrity": "sha512-2ph8KiZ8Bn141hWuC3ErtpJ+Ui8p2MJQn8TJNRSRmLmnCIOCtHfiBM9R7WLRPeUG/q6hXyHOxWQ8DvRSX+ixvQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/playfair-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/playfair-display/-/playfair-display-5.3.0.tgz",
"integrity": "sha512-IHdzTvE8eGIXHteZql7KXoqdc3Gfm+Zs1u4XlSC3MeplkwviT3sx/P6gI0tLwg/xIYLF4ZJYveZ5V+bl9cnDwA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/atkinson-hyperlegible": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.3.0.tgz",
"integrity": "sha512-Sp8Ve8+rQmENY2Lv02VH7sLpntzTTvnncnJMTbRE7DX6TLxGraN4ykZfzaUWIoCFmaomwkvNiqmlPkrKBuDoSA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2422,18 +2459,18 @@
}
},
"node_modules/@rollup/plugin-terser": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
"integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz",
"integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"serialize-javascript": "^6.0.1",
"serialize-javascript": "^7.0.3",
"smob": "^1.0.0",
"terser": "^5.17.4"
},
"engines": {
"node": ">=14.0.0"
"node": ">=20.0.0"
},
"peerDependencies": {
"rollup": "^2.0.0||^3.0.0||^4.0.0"
@@ -3533,24 +3570,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
"license": "MIT"
},
"node_modules/@use-gesture/react": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
"license": "MIT",
"dependencies": {
"@use-gesture/core": "10.3.1"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
@@ -6679,15 +6698,6 @@
"react": "^19.2.4"
}
},
"node_modules/react-masonry-css": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.16.tgz",
"integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -6699,9 +6709,9 @@
}
},
"node_modules/react-router": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -6721,12 +6731,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.0"
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
@@ -7434,17 +7444,9 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+5 -4
View File
@@ -10,15 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/lora": "^5.3.0",
"@fontsource-variable/playfair-display": "^5.3.0",
"@fontsource/atkinson-hyperlegible": "^5.3.0",
"@tanstack/react-query": "^5.90.20",
"@use-gesture/react": "^10.3.1",
"framer-motion": "^12.34.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-masonry-css": "^1.0.16",
"react-router-dom": "^7.13.0",
"react-router-dom": "^7.18.4",
"react-swipeable": "^7.0.2",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.11"
},
"devDependencies": {
+12
View File
@@ -0,0 +1,12 @@
// Applies the saved theme before first paint (kept out of index.html so the
// Content-Security-Policy can forbid inline scripts).
(function () {
try {
var t = localStorage.getItem('theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
var root = document.documentElement;
if (t === 'dark') root.classList.add('dark');
if (t === 'sepia') root.classList.add('sepia');
root.style.colorScheme = t === 'dark' ? 'dark' : 'light';
} catch (e) {}
})();
+54 -18
View File
@@ -1,47 +1,83 @@
import { useEffect } from 'react';
import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAuthStore } from './stores/authStore';
import { useAuthStore, clearUserData } from './stores/authStore';
import { authApi } from './api/auth';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { RootLayout } from './layouts/RootLayout';
import { ApiError, setUnauthorizedHandler } from './api/client';
import { ProtectedRoute } from './components/ProtectedRoute';
import { UpdateToast } from './components/UpdateToast';
// Route-level code splitting: the reader app and the auth screens load separately.
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })));
const RegisterPage = lazy(() => import('./pages/RegisterPage').then((m) => ({ default: m.RegisterPage })));
const RootLayout = lazy(() => import('./layouts/RootLayout').then((m) => ({ default: m.RootLayout })));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000,
retry: (count, err) => !(err instanceof ApiError && err.status < 500) && count < 2,
staleTime: 60 * 1000,
// Live updates arrive over the WebSocket; refocusing the tab must not
// refetch every loaded page.
refetchOnWindowFocus: false,
// Let the service worker answer from cache when the network is down.
networkMode: 'offlineFirst',
},
mutations: {
networkMode: 'offlineFirst',
},
},
});
setUnauthorizedHandler(() => {
if (!useAuthStore.getState().isAuthenticated) return;
useAuthStore.getState().logout();
queryClient.clear();
void clearUserData();
});
function AuthChecker({ children }: { children: React.ReactNode }) {
const setUser = useAuthStore((state) => state.setUser);
const setLoading = useAuthStore((state) => state.setLoading);
useEffect(() => {
authApi.getMe()
authApi
.getMe()
.then((user) => setUser(user))
.catch(() => setUser(null));
}, [setUser]);
.catch((err) => {
// Only a real 401 means "logged out"; offline or 5xx keeps the session.
if (err instanceof ApiError && err.status === 401) setUser(null);
else setLoading(false);
});
}, [setUser, setLoading]);
return <>{children}</>;
}
function PageFallback() {
return (
<div className="min-h-screen flex items-center justify-center bg-carbon" role="status" aria-label="Chargement">
<div className="w-10 h-10 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
</div>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthChecker>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<RootLayout />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Suspense fallback={<PageFallback />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<RootLayout />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
<UpdateToast />
</AuthChecker>
</BrowserRouter>
</QueryClientProvider>
+47 -85
View File
@@ -1,130 +1,92 @@
import { API_BASE, handleResponse } from './client';
import { apiFetch } from './client';
export class ApiError extends Error {
public status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = 'ApiError';
}
}
export { ApiError } from './client';
/** List item: light payload (no HTML content). */
export interface Article {
id: string;
feed_id: string;
guid: string;
title: string;
url?: string;
content?: string;
summary?: string;
excerpt?: string;
ai_summary?: string;
author?: string;
image_url?: string;
published_at?: string;
sort_at: string;
is_read: boolean;
is_favorite: boolean;
read_at?: string;
created_at: string;
word_count: number;
reading_time: number;
feed_title?: string;
}
/** Full article returned by GET /articles/{id}. HTML is sanitized server-side. */
export interface ArticleDetail extends Article {
content?: string;
summary?: string;
}
export interface ListArticlesOptions {
limit?: number;
offset?: number;
cursor?: string;
unread?: boolean;
favorite?: boolean;
feed_id?: string;
favorites?: boolean;
feedId?: string | null;
}
/** Keyset cursor for the page following `last`. */
export function cursorAfter(last: Article): string {
return `${last.sort_at},${last.id}`;
}
export const articlesApi = {
async list(options: ListArticlesOptions = {}): Promise<Article[]> {
list(options: ListArticlesOptions = {}): Promise<Article[]> {
const params = new URLSearchParams();
if (options.limit) params.append('limit', options.limit.toString());
if (options.offset) params.append('offset', options.offset.toString());
if (options.unread) params.append('unread', 'true');
if (options.favorite) params.append('favorite', 'true');
if (options.limit) params.set('limit', String(options.limit));
if (options.cursor) params.set('cursor', options.cursor);
if (options.unread) params.set('unread', 'true');
let url = `${API_BASE}/articles`;
if (options.favorite) {
url = `${API_BASE}/articles/favorites`;
} else if (options.feed_id) {
url = `${API_BASE}/feeds/${options.feed_id}/articles`;
}
let path = '/articles';
if (options.favorites) path = '/articles/favorites';
else if (options.feedId) path = `/feeds/${options.feedId}/articles`;
if (params.toString()) {
url += `?${params.toString()}`;
}
const response = await fetch(url, {
credentials: 'include',
});
return handleResponse<Article[]>(response);
const qs = params.toString();
return apiFetch<Article[]>(qs ? `${path}?${qs}` : path);
},
async get(id: string): Promise<Article> {
const response = await fetch(`${API_BASE}/articles/${id}`, {
credentials: 'include',
});
return handleResponse<Article>(response);
get(id: string): Promise<ArticleDetail> {
return apiFetch<ArticleDetail>(`/articles/${id}`);
},
async markRead(id: string): Promise<{ is_read: boolean }> {
const response = await fetch(`${API_BASE}/articles/${id}/read`, {
method: 'POST',
credentials: 'include',
});
return handleResponse(response);
markRead(id: string): Promise<{ is_read: boolean }> {
return apiFetch(`/articles/${id}/read`, { method: 'POST' });
},
async markUnread(id: string): Promise<{ is_read: boolean }> {
const response = await fetch(`${API_BASE}/articles/${id}/read`, {
method: 'DELETE',
credentials: 'include',
});
return handleResponse(response);
markUnread(id: string): Promise<{ is_read: boolean }> {
return apiFetch(`/articles/${id}/read`, { method: 'DELETE' });
},
async toggleFavorite(id: string): Promise<{ is_favorite: boolean }> {
const response = await fetch(`${API_BASE}/articles/${id}/favorite`, {
method: 'POST',
credentials: 'include',
});
return handleResponse(response);
toggleFavorite(id: string): Promise<{ is_favorite: boolean }> {
return apiFetch(`/articles/${id}/favorite`, { method: 'POST' });
},
async markAllRead(feedId: string): Promise<{ message: string }> {
const response = await fetch(`${API_BASE}/feeds/${feedId}/read-all`, {
method: 'POST',
credentials: 'include',
});
return handleResponse(response);
markAllRead(feedId: string): Promise<{ message: string; count: number }> {
return apiFetch(`/feeds/${feedId}/read-all`, { method: 'POST' });
},
async markAllReadGlobal(): Promise<{ message: string }> {
const response = await fetch(`${API_BASE}/articles/read-all`, {
method: 'POST',
credentials: 'include',
});
return handleResponse(response);
markAllReadGlobal(): Promise<{ message: string; count: number }> {
return apiFetch('/articles/read-all', { method: 'POST' });
},
async search(query: string, limit: number = 50, offset: number = 0): Promise<Article[]> {
const params = new URLSearchParams();
params.append('q', query);
params.append('limit', limit.toString());
params.append('offset', offset.toString());
const response = await fetch(`${API_BASE}/articles/search?${params.toString()}`, {
credentials: 'include',
});
return handleResponse<Article[]>(response);
search(query: string, limit = 30, offset = 0): Promise<Article[]> {
const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) });
return apiFetch<Article[]>(`/articles/search?${params}`);
},
async summarize(id: string): Promise<{ summary: string }> {
const response = await fetch(`${API_BASE}/articles/${id}/summarize`, {
method: 'POST',
credentials: 'include',
});
return handleResponse(response);
summarize(id: string): Promise<{ summary: string }> {
return apiFetch(`/articles/${id}/summarize`, { method: 'POST' });
},
};
+17
View File
@@ -9,10 +9,27 @@ export class ApiError extends Error {
}
}
type UnauthorizedListener = () => void;
let onUnauthorized: UnauthorizedListener | null = null;
/** Registers the handler run when an authenticated call gets a 401 (session expired). */
export function setUnauthorizedHandler(fn: UnauthorizedListener) {
onUnauthorized = fn;
}
export async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
if (response.status === 401 && !response.url.includes('/auth/')) {
onUnauthorized?.();
}
throw new ApiError(response.status, error.error || 'Request failed');
}
return response.json();
}
/** fetch() with cookies and JSON handling. */
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { credentials: 'include', ...init });
return handleResponse<T>(response);
}
+5 -12
View File
@@ -1,4 +1,4 @@
import { API_BASE, handleResponse } from './client';
import { API_BASE, handleResponse, apiFetch } from './client';
export interface Feed {
id: string;
@@ -20,11 +20,8 @@ export interface AddFeedRequest {
}
export const feedsApi = {
async list(): Promise<Feed[]> {
const response = await fetch(`${API_BASE}/feeds`, {
credentials: 'include',
});
return handleResponse<Feed[]>(response);
list(): Promise<Feed[]> {
return apiFetch<Feed[]>('/feeds');
},
async add(data: AddFeedRequest): Promise<Feed> {
@@ -62,12 +59,8 @@ export const feedsApi = {
await handleResponse(response);
},
async refresh(): Promise<{ message: string }> {
const response = await fetch(`${API_BASE}/feeds/refresh`, {
method: 'POST',
credentials: 'include',
});
return handleResponse(response);
refresh(): Promise<{ message: string }> {
return apiFetch('/feeds/refresh', { method: 'POST' });
},
async importOPML(file: File): Promise<{ imported: number; skipped: number; errors?: string[] }> {
+106 -98
View File
@@ -1,23 +1,23 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { feedsApi } from '../api/feeds';
import { ApiError } from '../api/client';
interface AddFeedModalProps {
isOpen: boolean;
onClose: () => void;
}
export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
export function AddFeedModal({ onClose }: AddFeedModalProps) {
const [url, setUrl] = useState('');
const [importResult, setImportResult] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
const queryClient = useQueryClient();
const addFeedMutation = useMutation({
mutationFn: (u: string) => feedsApi.add({ url: u }),
mutationFn: (u: string) => feedsApi.add({ url: u.trim() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
setUrl('');
onClose();
},
});
@@ -26,108 +26,116 @@ export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
mutationFn: (file: File) => feedsApi.importOPML(file),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
queryClient.invalidateQueries({ queryKey: ['articles'] });
window.alert(`${res.imported} flux importé(s), ${res.skipped} ignoré(s).`);
onClose();
setImportResult(
`${res.imported} flux importé${res.imported > 1 ? 's' : ''}, ${res.skipped} ignoré${res.skipped > 1 ? 's' : ''}.`,
);
},
});
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
previousFocus.current = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
return () => {
window.removeEventListener('keydown', onKey);
previousFocus.current?.focus?.();
};
}, [onClose]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addFeedMutation.mutate(url);
};
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
};
const addError =
addFeedMutation.error instanceof ApiError && addFeedMutation.error.status === 409
? 'Vous êtes déjà abonné à ce flux.'
: 'Échec de l’ajout. Vérifiez l’adresse du flux.';
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="add-feed-title"
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 animate-fade-in"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="add-feed-title"
>
<div className="w-full max-w-md surface-card p-6 sm:p-8 animate-rise" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-2">
<h2 id="add-feed-title" className="text-2xl font-serif italic text-nature">Nouveau flux</h2>
<button onClick={onClose} className="icon-btn" aria-label="Fermer">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-paper-muted text-sm mb-6 leading-relaxed font-reading">
Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et les articles automatiquement.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
addFeedMutation.mutate(url);
}}
className="space-y-5"
>
<motion.div
className="w-full max-w-md surface-card p-8"
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
<div className="space-y-2">
<label htmlFor="feed-url" className="block eyebrow text-paper-muted">URL du flux</label>
<input
id="feed-url"
type="url"
inputMode="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input-field"
placeholder="https://exemple.com/feed"
required
autoFocus
aria-invalid={addFeedMutation.isError}
aria-describedby={addFeedMutation.isError ? 'feed-url-error' : undefined}
/>
</div>
{addFeedMutation.isError && (
<p id="feed-url-error" className="text-danger text-sm" role="alert">{addError}</p>
)}
<div className="flex justify-end gap-3">
<button type="button" onClick={onClose} className="btn-ghost">Annuler</button>
<button type="submit" disabled={url.trim().length < 5 || addFeedMutation.isPending} className="btn-primary">
{addFeedMutation.isPending ? 'Ajout…' : "S'abonner"}
</button>
</div>
</form>
{/* OPML import */}
<div className="mt-6 pt-5 border-t border-paper-muted/12">
<input
ref={fileInputRef}
type="file"
accept=".opml,.xml,text/xml,application/xml"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
className="btn-ghost w-full justify-center"
>
<div className="flex justify-between items-center mb-2">
<h2 id="add-feed-title" className="text-2xl font-serif italic text-nature">Nouveau flux</h2>
<button onClick={onClose} className="icon-btn" aria-label="Fermer">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-paper-muted text-sm mb-6 leading-relaxed font-reading">
Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et le contenu automatiquement.
</p>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label htmlFor="feed-url" className="block eyebrow text-paper-muted">URL du flux</label>
<input
id="feed-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input-field"
placeholder="https://exemple.com/feed"
required
autoFocus
/>
</div>
{addFeedMutation.isError && (
<p className="text-danger text-xs" role="alert">Échec de l'ajout. Vérifiez l'URL du flux.</p>
)}
<div className="flex justify-end gap-3">
<button type="button" onClick={onClose} className="btn-ghost">Annuler</button>
<button type="submit" disabled={url.length < 5 || addFeedMutation.isPending} className="btn-primary">
{addFeedMutation.isPending ? 'Ajout…' : "S'abonner"}
</button>
</div>
</form>
{/* OPML import */}
<div className="mt-6 pt-5 border-t border-paper-muted/12">
<input ref={fileInputRef} type="file" accept=".opml,.xml,text/xml" className="hidden" onChange={handleFile} />
<button
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
className="btn-ghost w-full justify-center"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{importMutation.isPending ? 'Import en cours…' : 'Importer un fichier OPML'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{importMutation.isPending ? 'Import en cours…' : 'Importer un fichier OPML'}
</button>
<p className="text-sm text-center mt-2" role="status" aria-live="polite">
{importResult && <span className="text-nature">{importResult}</span>}
{importMutation.isError && <span className="text-danger">Fichier OPML invalide ou trop volumineux.</span>}
</p>
</div>
</div>
</div>
);
}
+102 -130
View File
@@ -1,113 +1,97 @@
import { useState } from 'react';
import { memo, useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import { type Article, articlesApi } from '../api/articles';
import type { Article } from '../api/articles';
import { formatRelative, readingTimeLabel } from '../lib/format';
interface ArticleCardProps {
article: Article;
onClick: (article: Article) => void;
onToggleRead: (id: string, is_read: boolean) => void;
onToggleFavorite: (id: string) => void;
/** Position in the list, used by keyboard navigation (j/k). */
index: number;
isCurrent?: boolean;
onOpen: (article: Article) => void;
onToggleRead: (article: Article) => void;
onToggleFavorite: (article: Article) => void;
}
const FALLBACK_IMAGE =
'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800';
function plainExcerpt(html: string, max = 130): string {
const text = html.replace(/<[^>]*>?/gm, '').trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
/** Initial-letter cover used when a feed provides no image (no third-party fallback). */
function CoverFallback({ title, feed }: { title: string; feed?: string }) {
return (
<div className="w-full h-full bg-gradient-to-br from-nature/90 to-nature-dark flex items-end p-5">
<span className="font-serif italic text-on-nature/90 text-lg leading-snug line-clamp-3">{feed || title}</span>
</div>
);
}
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
const [swipeOffset, setSwipeOffset] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
const [isSummarizing, setIsSummarizing] = useState(false);
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
const [offset, setOffset] = useState(0);
const [imgFailed, setImgFailed] = useState(false);
const handlers = useSwipeable({
onSwiping: (event) => {
if (Math.abs(event.deltaX) < 100) {
setSwipeOffset(event.deltaX);
setIsSwiping(true);
}
// Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text
// selection) never trigger it.
const swipe = useSwipeable({
onSwiping: (e) => {
if (e.dir === 'Left' || e.dir === 'Right') setOffset(Math.max(-110, Math.min(110, e.deltaX)));
},
onSwipedRight: () => {
if (swipeOffset > 50) onToggleRead(article.id, !article.is_read);
resetSwipe();
onSwiped: (e) => {
if (e.deltaX > 90) onToggleRead(article);
else if (e.deltaX < -90) onToggleFavorite(article);
setOffset(0);
},
onSwipedLeft: () => {
if (swipeOffset < -50) onToggleFavorite(article.id);
resetSwipe();
},
onSwiped: () => resetSwipe(),
trackMouse: true,
trackMouse: false,
delta: 30,
preventScrollOnSwipe: false,
delta: 50,
});
const resetSwipe = () => {
setSwipeOffset(0);
setIsSwiping(false);
};
const handleSummarize = async (e: React.MouseEvent) => {
e.stopPropagation();
if (article.ai_summary || isSummarizing) return;
setIsSummarizing(true);
try {
await articlesApi.summarize(article.id);
} catch (error) {
console.error('Failed to summarize:', error);
} finally {
setIsSummarizing(false);
}
};
const showImage = article.image_url && !imgFailed;
const date = article.published_at || article.created_at;
return (
<div className="relative">
{/* Swipe action hints behind the card */}
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none">
<span
className={`chip transition-opacity duration-300 ${swipeOffset > 10 ? 'opacity-100' : 'opacity-0'}`}
>
{article.is_read ? 'Marquer non lu' : 'Marquer lu'}
<div className="relative cv-auto" data-index={index}>
{/* Swipe hints behind the card */}
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none" aria-hidden="true">
<span className={`chip transition-opacity ${offset > 20 ? 'opacity-100' : 'opacity-0'}`}>
{article.is_read ? 'Non lu' : 'Lu'}
</span>
<span
className={`chip bg-earth/15 text-earth transition-opacity duration-300 ${swipeOffset < -10 ? 'opacity-100' : 'opacity-0'}`}
>
<span className={`chip bg-earth/15 text-earth transition-opacity ${offset < -20 ? 'opacity-100' : 'opacity-0'}`}>
Favori
</span>
</div>
<article
{...handlers}
onClick={() => !isSwiping && Math.abs(swipeOffset) < 5 && onClick(article)}
className={`group relative z-10 cursor-pointer flex flex-col overflow-hidden rounded-2xl
bg-carbon-light border transition-colors
${article.is_read ? 'border-paper-muted/10' : 'border-nature/20'}
hover:border-nature/40`}
{...swipe}
className={`group relative z-10 flex flex-col h-full overflow-hidden rounded-2xl bg-carbon-light border transition-[border-color,box-shadow,opacity]
${article.is_read ? 'border-paper-muted/10' : 'border-nature/25'}
${isCurrent ? 'ring-2 ring-nature ring-offset-2 ring-offset-carbon' : ''}
hover:border-nature/45 focus-within:border-nature/45`}
style={{
transform: `translateX(${swipeOffset}px)`,
transition: isSwiping ? 'none' : 'transform 0.45s cubic-bezier(0.22,1,0.36,1)',
transform: offset ? `translateX(${offset}px)` : undefined,
transition: offset ? 'none' : 'transform 0.35s cubic-bezier(0.22,1,0.36,1)',
boxShadow: 'var(--shadow-soft)',
}}
>
{/* Cover */}
<div className="aspect-[16/9] overflow-hidden relative bg-carbon-dark">
<div
className="w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
style={{ backgroundImage: `url(${article.image_url || FALLBACK_IMAGE})` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/45 via-transparent to-transparent" />
<span className="absolute bottom-3 left-3 chip bg-carbon-light/90 backdrop-blur-sm shadow-sm">
<div className={`aspect-[16/9] overflow-hidden relative bg-carbon-dark ${article.is_read ? 'opacity-75' : ''}`}>
{showImage ? (
<img
src={article.image_url}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
width={640}
height={360}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
onError={() => setImgFailed(true)}
/>
) : (
<CoverFallback title={article.title} feed={article.feed_title} />
)}
<span className="absolute bottom-3 left-3 chip bg-carbon-light/95 shadow-sm max-w-[75%] truncate">
{article.feed_title || 'Journal'}
</span>
{!article.is_read && (
<div
className="absolute top-0 right-0 w-24 h-24 overflow-hidden pointer-events-none z-20"
aria-label="Article non lu"
>
<span className="absolute top-[18px] -right-[34px] w-[140px] rotate-45 bg-nature text-white text-[10px] font-extrabold uppercase tracking-[0.2em] text-center py-1.5 shadow-lg shadow-black/20 border-b border-white/10">
<div className="absolute top-0 right-0 w-24 h-24 overflow-hidden pointer-events-none" aria-hidden="true">
<span className="absolute top-[18px] -right-[34px] w-[140px] rotate-45 bg-nature text-on-nature text-[11px] font-extrabold uppercase tracking-[0.18em] text-center py-1.5 shadow-lg shadow-black/20">
Nouveau
</span>
</div>
@@ -115,78 +99,66 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
</div>
{/* Body */}
<div className="flex-1 flex flex-col gap-3 px-5 pt-4 pb-3">
<div className="flex-1 flex flex-col gap-2.5 px-5 pt-4 pb-2">
<h2
className={`text-2xl font-serif leading-snug transition-colors group-hover:text-nature
${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-bold'}`}
className={`text-xl font-serif leading-snug text-balance transition-colors group-hover:text-nature
${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-semibold'}`}
>
{article.title}
{/* Stretched button: the whole card is clickable and keyboard-focusable. */}
<button
type="button"
onClick={() => onOpen(article)}
className="text-left after:absolute after:inset-0 after:z-0 focus-visible:outline-none"
>
{!article.is_read && <span className="sr-only">Non lu : </span>}
{article.title}
</button>
</h2>
{article.ai_summary ? (
<span className="chip self-start">✨ Résumé disponible</span>
) : article.summary ? (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-2 font-reading">
{plainExcerpt(article.summary)}
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">
<span className="text-nature font-semibold not-italic">✨ </span>
{article.ai_summary}
</p>
) : article.excerpt ? (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">{article.excerpt}</p>
) : null}
<div className="mt-auto flex items-center justify-between pt-3 border-t border-paper-muted/10">
<div className="flex items-center gap-1">
<div className="mt-auto flex items-center justify-between pt-2 border-t border-paper-muted/10">
<p className="text-xs text-paper-muted">
<time dateTime={date}>{formatRelative(date)}</time>
<span aria-hidden="true"> · </span>
{readingTimeLabel(article.reading_time)}
</p>
<div className="relative z-10 flex items-center -mr-2">
<button
onClick={(e) => { e.stopPropagation(); onToggleRead(article.id, !article.is_read); }}
className={`p-2 rounded-full border transition-all
${article.is_read
? 'text-nature/40 border-transparent hover:bg-nature/5'
: 'text-nature border-nature/20 hover:bg-nature/10'}`}
title={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
onClick={() => onToggleRead(article)}
className="w-11 h-11 inline-flex items-center justify-center rounded-full text-nature hover:bg-nature/10 transition-colors"
title={article.is_read ? 'Marquer comme non lu (m)' : 'Marquer comme lu (m)'}
aria-label={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
>
<svg className="w-4 h-4" fill={article.is_read ? 'none' : 'currentColor'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
<svg className="w-[18px] h-[18px]" fill={article.is_read ? 'none' : 'currentColor'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<circle cx="12" cy="12" r="5" strokeWidth={1.8} />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onToggleFavorite(article.id); }}
className={`p-2 rounded-full transition-all ${article.is_favorite ? 'text-earth' : 'text-paper-muted hover:text-earth hover:bg-earth/10'}`}
title={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
aria-label={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
onClick={() => onToggleFavorite(article)}
className={`w-11 h-11 inline-flex items-center justify-center rounded-full transition-colors ${article.is_favorite ? 'text-earth' : 'text-paper-muted hover:text-earth hover:bg-earth/10'}`}
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
aria-label="Favori"
aria-pressed={article.is_favorite}
>
<svg className="w-4 h-4" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
<svg className="w-[18px] h-[18px]" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
onClick={handleSummarize}
disabled={isSummarizing || !!article.ai_summary}
className={`p-2 rounded-full transition-all ${article.ai_summary ? 'text-nature opacity-40 cursor-default' : 'text-paper-muted hover:text-nature hover:bg-nature/10'}`}
title={article.ai_summary ? 'Résumé déjà généré' : 'Générer un résumé IA'}
aria-label={article.ai_summary ? 'Résumé déjà généré' : 'Générer un résumé IA'}
>
{isSummarizing ? (
<svg className="w-4 h-4 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>
) : (
<span className="text-sm leading-none">✨</span>
)}
</button>
</div>
<time className="text-[11px] text-paper-muted/70 font-medium">
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</time>
</div>
</div>
</article>
</div>
);
}
/** Memoised: a card re-renders only when its own article object changes. */
export const ArticleCard = memo(ArticleCardImpl);
+12 -10
View File
@@ -17,7 +17,7 @@ export function MobileTopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: Mob
return (
<>
<header className="md:hidden sticky top-0 z-30 bg-carbon-light/85 backdrop-blur-xl border-b border-paper-muted/12 px-4 py-3">
<header className="md:hidden sticky top-0 z-30 bg-carbon-light/95 border-b border-paper-muted/12 px-4 py-2.5" style={{ paddingTop: 'max(0.625rem, env(safe-area-inset-top))' }}>
<div className="flex items-center justify-between gap-3">
<button onClick={() => onSelectFeed(null)} className="text-nature text-xl font-serif italic shrink-0">
FlowReader
@@ -38,28 +38,30 @@ export function MobileTopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: Mob
</div>
{/* Feed selector chips */}
<div className="flex gap-2 mt-3 overflow-x-auto pb-1 -mx-1 px-1 no-scrollbar">
<nav className="flex gap-2 mt-2.5 overflow-x-auto pb-1 -mx-1 px-1 no-scrollbar" aria-label="Flux">
{[
{ id: null as string | null, label: 'Tous' },
{ id: 'favorites' as string | null, label: 'Favoris' },
...(feeds ?? []).map((f) => ({ id: f.id as string | null, label: f.title })),
{ id: null as string | null, label: 'Tous', unread: 0 },
{ id: 'favorites' as string | null, label: 'Favoris', unread: 0 },
...(feeds ?? []).map((f) => ({ id: f.id as string | null, label: f.title, unread: f.unread_count ?? 0 })),
].map((item) => (
<button
key={item.id ?? 'all'}
onClick={() => onSelectFeed(item.id)}
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
aria-current={selectedFeedId === item.id ? 'page' : undefined}
className={`shrink-0 min-h-10 px-3.5 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${
selectedFeedId === item.id
? 'bg-nature text-white'
: 'bg-nature/8 text-paper-muted hover:text-nature'
? 'bg-nature text-on-nature'
: 'bg-nature/10 text-paper-muted hover:text-nature'
}`}
>
{item.label}
{item.unread ? <span className="ml-1.5 opacity-80">{item.unread}</span> : null}
</button>
))}
</div>
</nav>
</header>
<AddFeedModal isOpen={isAddOpen} onClose={() => setIsAddOpen(false)} />
{isAddOpen && <AddFeedModal onClose={() => setIsAddOpen(false)} />}
</>
);
}
+6 -6
View File
@@ -4,18 +4,18 @@ import { useAuthStore } from '../stores/authStore';
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuthStore();
// A persisted session renders immediately; /users/me revalidates in the
// background and any 401 logs the user out.
if (isAuthenticated) return <Outlet />;
if (isLoading) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-5 bg-carbon">
<div className="min-h-screen flex flex-col items-center justify-center gap-5 bg-carbon" role="status">
<div className="w-11 h-11 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
<p className="eyebrow text-paper-muted">Chargement…</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
return <Navigate to="/login" replace />;
}
+43
View File
@@ -0,0 +1,43 @@
import { forwardRef, useEffect, useState } from 'react';
interface SearchBoxProps {
onSearch: (query: string) => void;
}
/**
* Owns the raw input value so typing only re-renders this box; the list
* receives the debounced query.
*/
export const SearchBox = forwardRef<HTMLInputElement, SearchBoxProps>(function SearchBox({ onSearch }, ref) {
const [value, setValue] = useState('');
useEffect(() => {
const t = window.setTimeout(() => onSearch(value.trim()), 350);
return () => window.clearTimeout(t);
}, [value, onSearch]);
return (
<div className="relative group">
<span className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-paper-muted 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
ref={ref}
type="search"
aria-label="Rechercher dans vos articles"
placeholder="Rechercher… /"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setValue('');
e.currentTarget.blur();
}
}}
className="input-field !py-2.5 pl-10 pr-4 !rounded-full w-full sm:w-56 sm:focus:w-72 transition-[width] text-sm"
/>
</div>
);
});
+70 -80
View File
@@ -1,120 +1,110 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { Article } from '../api/articles';
interface ShareButtonProps {
article: Article;
article: Pick<Article, 'title' | 'url' | 'excerpt'>;
className?: string;
}
export function ShareButton({ article, className = "" }: ShareButtonProps) {
export function ShareButton({ article, className = 'icon-btn' }: ShareButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const url = article.url || window.location.href;
const shareData = {
title: article.title || 'Article FlowReader',
text: article.summary ? article.summary.substring(0, 100) + '...' : (article.title || ''),
url: article.url || window.location.href,
};
// Escape closes only the menu, not the reader behind it.
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopImmediatePropagation();
setIsOpen(false);
}
};
window.addEventListener('keydown', onKey, true);
menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
return () => window.removeEventListener('keydown', onKey, true);
}, [isOpen]);
const handleShare = async () => {
// Try Native Share API first (Mobile & Supported Browsers)
if (navigator.share) {
try {
await navigator.share(shareData);
await navigator.share({ title: article.title, text: article.excerpt?.slice(0, 120), url });
return;
} catch (err) {
console.log('Error sharing:', err);
// If user cancelled, do nothing. If error, maybe fallback?
// Usually if navigator.share exists, we trust it unless it throws.
// But on some desktops it might exist but fail.
if ((err as DOMException)?.name === 'AbortError') return;
}
}
// Fallback to custom menu
setIsOpen(!isOpen);
setIsOpen((o) => !o);
};
const copyToClipboard = () => {
navigator.clipboard.writeText(article.url || window.location.href);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} finally {
setIsOpen(false);
}
};
const openLink = (href: string) => {
window.open(href, '_blank', 'noopener,noreferrer');
setIsOpen(false);
};
const openLink = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
setIsOpen(false);
};
const enc = encodeURIComponent;
const items = [
{ label: 'E-mail', href: `mailto:?subject=${enc(article.title)}&body=${enc(url)}` },
{ label: 'WhatsApp', href: `https://wa.me/?text=${enc(`${article.title} ${url}`)}` },
{ label: 'X / Twitter', href: `https://twitter.com/intent/tweet?text=${enc(article.title)}&url=${enc(url)}` },
{ label: 'LinkedIn', href: `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}` },
];
return (
<div className="relative">
<button
onClick={handleShare}
className={`p-2 rounded-full transition-all duration-300 hover:bg-nature/10 text-nature ${className}`}
className={className}
title="Partager"
aria-label="Partager"
aria-haspopup="menu"
aria-expanded={isOpen}
>
<svg className="w-5 h-5 space-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
{copied && (
<span role="status" className="absolute -top-9 left-1/2 -translate-x-1/2 chip whitespace-nowrap">Lien copié</span>
)}
{/* Desktop Fallback Menu */}
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)}></div>
<div className="absolute right-0 bottom-12 mt-2 w-48 bg-white dark:bg-carbon border border-gray-200 dark:border-white/10 rounded-xl shadow-xl z-50 overflow-hidden text-sm animate-in fade-in zoom-in-95 duration-200 origin-bottom-right">
<div className="py-1">
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div
ref={menuRef}
role="menu"
className="absolute right-0 top-12 w-52 surface-card z-50 py-1 text-sm animate-fade-in"
>
{items.map((it) => (
<button
onClick={() => openLink(`https://wa.me/?text=${encodeURIComponent((article.title || '') + ' ' + (article.url || ''))}`)}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 flex items-center gap-3 text-gray-700 dark:text-gray-200"
key={it.label}
role="menuitem"
onClick={() => openLink(it.href)}
className="w-full text-left px-4 py-2.5 min-h-11 text-paper-white hover:bg-nature/10 focus-visible:bg-nature/10"
>
<span className="text-green-500">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448l-6.305 1.654zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z" /></svg>
</span>
WhatsApp
{it.label}
</button>
<button
onClick={() => openLink(`mailto:?subject=${encodeURIComponent(article.title || '')}&body=${encodeURIComponent(article.url || '')}`)}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 flex items-center gap-3 text-gray-700 dark:text-gray-200"
>
<span className="text-gray-500">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</span>
Email
</button>
<button
onClick={() => openLink(`https://twitter.com/intent/tweet?text=${encodeURIComponent(article.title || '')}&url=${encodeURIComponent(article.url || '')}`)}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 flex items-center gap-3 text-gray-700 dark:text-gray-200"
>
<span className="text-blue-400">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /></svg>
</span>
X / Twitter
</button>
<button
onClick={() => openLink(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(article.url || '')}`)}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 flex items-center gap-3 text-gray-700 dark:text-gray-200"
>
<span className="text-blue-700">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" /></svg>
</span>
LinkedIn
</button>
<div className="border-t border-gray-200 dark:border-white/10 my-1"></div>
<button
onClick={copyToClipboard}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 flex items-center gap-3 text-gray-700 dark:text-gray-200"
>
<span className="text-gray-400">
{copied ? (
<svg className="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
)}
</span>
{copied ? 'Copié !' : 'Copier le lien'}
</button>
</div>
))}
<div className="border-t border-paper-muted/15 my-1" />
<button
role="menuitem"
onClick={copyToClipboard}
className="w-full text-left px-4 py-2.5 min-h-11 text-paper-white hover:bg-nature/10 focus-visible:bg-nature/10"
>
Copier le lien
</button>
</div>
</>
)}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef } from 'react';
const GROUPS: { title: string; keys: [string, string][] }[] = [
{
title: 'Liste',
keys: [
['j / k', 'Article suivant / précédent'],
['o · Entrée', 'Ouvrir l’article'],
['m', 'Marquer lu / non lu'],
['s · f', 'Favori'],
['v', 'Ouvrir l’original'],
['/', 'Rechercher'],
['u', 'Basculer Non lus / Tous'],
['r', 'Actualiser les flux'],
],
},
{
title: 'Lecture',
keys: [
['Espace', 'Défiler, puis article suivant'],
['j · → / k · ←', 'Suivant / précédent'],
['+ / -', 'Taille du texte'],
['Échap', 'Fermer'],
],
},
];
export function ShortcutsHelp({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
ref.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === '?') {
e.stopImmediatePropagation();
onClose();
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-nature-dark/50 animate-fade-in" onClick={onClose}>
<div
ref={ref}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
className="surface-card w-full max-w-lg p-7 outline-none animate-rise"
onClick={(e) => e.stopPropagation()}
>
<h2 id="shortcuts-title" className="text-2xl font-serif italic text-nature mb-5">Raccourcis clavier</h2>
<div className="grid sm:grid-cols-2 gap-6">
{GROUPS.map((g) => (
<section key={g.title}>
<h3 className="eyebrow text-paper-muted mb-3">{g.title}</h3>
<dl className="space-y-2 text-sm">
{g.keys.map(([k, label]) => (
<div key={k} className="flex items-center justify-between gap-3">
<dt className="text-paper-muted">{label}</dt>
<dd className="kbd shrink-0">{k}</dd>
</div>
))}
</dl>
</section>
))}
</div>
<button onClick={onClose} className="btn-ghost w-full mt-6">Fermer</button>
</div>
</div>
);
}
+58 -51
View File
@@ -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)} />}
</>
);
}
+10 -26
View File
@@ -1,24 +1,14 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { usePrefs } from '../stores/prefsStore';
type Theme = 'light' | 'dark';
function getInitialTheme(): Theme {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
function isDarkNow() {
return document.documentElement.classList.contains('dark');
}
/** Quick light/dark switch. Sepia and "system" live in the reader settings. */
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
const root = window.document.documentElement;
root.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
const isDark = theme === 'dark';
const theme = usePrefs((s) => s.theme);
const setTheme = usePrefs((s) => s.setTheme);
const isDark = theme === 'dark' || (theme === 'system' && isDarkNow());
return (
<button
@@ -26,16 +16,10 @@ export function ThemeToggle() {
className="icon-btn"
role="switch"
aria-checked={isDark}
aria-label={isDark ? 'Activer le mode clair' : 'Activer le mode sombre'}
aria-label="Mode sombre"
title={isDark ? 'Activer le mode clair' : 'Activer le mode sombre'}
>
<motion.span
key={theme}
initial={{ rotate: -90, opacity: 0, scale: 0.5 }}
animate={{ rotate: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="flex"
>
<span key={isDark ? 'd' : 'l'} className="flex animate-fade-in">
{isDark ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
@@ -45,7 +29,7 @@ export function ThemeToggle() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</motion.span>
</span>
</button>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useUpdate } from '../stores/updateStore';
/** Offers to load a new app version instead of reloading under the reader. */
export function UpdateToast() {
const apply = useUpdate((s) => s.apply);
const dismiss = useUpdate((s) => s.dismiss);
if (!apply) return null;
return (
<div
role="status"
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[70] flex items-center gap-3 pl-5 pr-2 py-2 rounded-full surface-card animate-rise"
>
<span className="text-sm text-paper-white">Nouvelle version disponible</span>
<button className="btn-primary !min-h-9 !px-4" onClick={apply}>Mettre à jour</button>
<button className="btn-ghost !min-h-9 !px-3" onClick={dismiss} aria-label="Plus tard">Plus tard</button>
</div>
);
}
+34 -33
View File
@@ -1,4 +1,5 @@
import type { Article } from '../../api/articles';
import { formatRelative, readingTimeLabel } from '../../lib/format';
interface FocusCardProps {
article: Article;
@@ -6,55 +7,55 @@ interface FocusCardProps {
}
export function FocusCard({ article, isTop = false }: FocusCardProps) {
const timeToRead = article.content ? Math.max(1, Math.ceil(article.content.length / 1000)) : 1;
const excerpt = article.summary ? article.summary.replace(/<[^>]*>?/gm, '').slice(0, 150) : '';
const date = article.published_at || article.created_at;
return (
<article className="w-full h-full bg-carbon-light rounded-3xl overflow-hidden flex flex-col relative select-none cursor-grab active:cursor-grabbing">
{/* Cover (60%) */}
<div className="h-[58%] relative overflow-hidden">
<article className="w-full h-full bg-carbon-light rounded-3xl overflow-hidden flex flex-col relative select-none">
{/* Cover */}
<div className="h-[52%] relative overflow-hidden bg-carbon-dark shrink-0">
{article.image_url ? (
<div className="w-full h-full bg-cover bg-center" style={{ backgroundImage: `url(${article.image_url})` }} />
<img
src={article.image_url}
alt=""
draggable={false}
decoding="async"
referrerPolicy="no-referrer"
className="w-full h-full object-cover pointer-events-none"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-nature to-nature-light p-8 flex items-end">
<h2 className="text-3xl font-serif italic text-white/95 leading-tight line-clamp-4 drop-shadow">
{article.title}
</h2>
<div className="w-full h-full bg-gradient-to-br from-nature to-nature-dark p-8 flex items-end">
<span className="text-2xl font-serif italic text-on-nature/95 leading-tight line-clamp-3">{article.feed_title}</span>
</div>
)}
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-black/55 to-transparent pointer-events-none" />
<span className="absolute top-4 right-4 bg-black/40 backdrop-blur-md text-white text-[10px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full border border-white/10">
~{timeToRead} min
<span className="absolute top-4 right-4 bg-black/55 text-white text-[11px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full">
{readingTimeLabel(article.reading_time)}
</span>
</div>
{/* Body (40%) */}
<div className="flex-1 p-6 flex flex-col">
<div className="flex items-center justify-between mb-3 text-[10px] uppercase tracking-widest font-bold text-paper-muted/70">
<span className="flex items-center gap-2 text-nature">
<span className="w-1.5 h-1.5 rounded-full bg-nature" />
{article.feed_title || 'Journal'}
</span>
<span>
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
weekday: 'short', hour: '2-digit', minute: '2-digit',
})}
{/* Body */}
<div className="flex-1 p-5 sm:p-6 flex flex-col min-h-0">
<div className="flex items-center justify-between gap-3 mb-3 text-[11px] uppercase tracking-widest font-bold text-paper-muted">
<span className="flex items-center gap-2 text-nature truncate">
<span className="w-1.5 h-1.5 rounded-full bg-nature shrink-0" aria-hidden="true" />
<span className="truncate">{article.feed_title || 'Journal'}</span>
</span>
<time dateTime={date} className="shrink-0">{formatRelative(date)}</time>
</div>
{article.image_url && (
<h2 className="text-xl sm:text-2xl font-serif italic text-paper-white leading-tight mb-3 line-clamp-2">
{article.title}
</h2>
)}
<h2 className="text-xl sm:text-2xl font-serif text-paper-white leading-tight mb-3 line-clamp-3 text-balance">
{article.title}
</h2>
{excerpt && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">{excerpt}…</p>
{(article.ai_summary || article.excerpt) && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">
{article.ai_summary || article.excerpt}
</p>
)}
{isTop && (
<p className="mt-auto pt-4 text-center text-[10px] uppercase tracking-widest font-bold text-paper-muted/40 animate-pulse">
Double-clic pour lire · Glisser pour trier
<p className="mt-auto pt-3 text-center text-[11px] uppercase tracking-widest font-bold text-paper-muted hidden sm:block">
← Garder · Lu → · Entrée pour lire
</p>
)}
</div>
+148 -137
View File
@@ -1,205 +1,216 @@
import { useState, useEffect, useRef } from 'react';
import { useDrag } from '@use-gesture/react';
import { motion, AnimatePresence, useMotionValue, useTransform, useSpring } from 'framer-motion';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion, useMotionValue, useTransform, type PanInfo } from 'framer-motion';
import type { Article } from '../../api/articles';
import { useArticleActions } from '../../hooks/useArticleActions';
import { FocusCard } from './FocusCard';
import { ReaderView } from '../ReaderView';
import { MobileReaderView } from '../MobileReaderView';
import { useIsMobile } from '../../hooks/useIsMobile';
import { Reader } from '../reader/Reader';
interface FocusCardStackProps {
articles: Article[];
onMarkRead: (id: string) => void;
onKeep: (id: string) => void;
onToggleFavorite: (id: string) => void;
onEmpty: () => void;
onExit?: () => void;
}
type Action = 'read' | 'keep';
interface SwipeableCardProps {
article: Article;
index: number;
isTop: boolean;
onAction: (action: 'read' | 'keep') => void;
onOpen: () => void;
hasDraggedRef: React.MutableRefObject<boolean>;
isProcessing: boolean;
onAction: (action: Action) => void;
}
/** A single draggable card. Drag state is local so unmount never "rebounds" the stack. */
function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef, isProcessing }: SwipeableCardProps) {
/** A single draggable card (framer-motion drag; no extra gesture library). */
function SwipeableCard({ article, index, isTop, onAction }: SwipeableCardProps) {
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-10, 10]);
const opacityRead = useTransform(x, [0, 150], [0, 1]);
const opacityKeep = useTransform(x, [-150, 0], [1, 0]);
const rotate = useTransform(x, [-220, 220], [-9, 9]);
const opacityRead = useTransform(x, [20, 140], [0, 1]);
const opacityKeep = useTransform(x, [-140, -20], [1, 0]);
const xSpring = useSpring(x, { stiffness: 1000, damping: 50 });
const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 });
const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => {
if (!isTop || isProcessing) return;
if (active && Math.abs(mx) > 5) hasDraggedRef.current = true;
const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50);
if (!active && trigger) {
onAction(mx > 0 ? 'read' : 'keep');
} else if (!active) {
x.set(0);
setTimeout(() => { hasDraggedRef.current = false; }, 50);
} else {
x.set(mx);
const onDragEnd = (_: unknown, info: PanInfo) => {
const { offset, velocity } = info;
if (Math.abs(offset.x) > 160 || (Math.abs(velocity.x) > 600 && Math.abs(offset.x) > 40)) {
onAction(offset.x > 0 ? 'read' : 'keep');
}
});
};
const scale = 1 - index * 0.05;
const yOffset = index * 18;
const y = index * 16;
return (
<motion.div
className="absolute inset-0 rounded-3xl origin-bottom"
style={{
x: isTop ? xSpring : 0,
rotate: isTop ? rotateSpring : 0,
zIndex: 100 - index,
cursor: isTop ? 'grab' : 'default',
boxShadow: 'var(--shadow-float)',
}}
{...(isTop ? (bind() as Record<string, unknown>) : {})}
onDoubleClick={() => { if (isTop && !hasDraggedRef.current) onOpen(); }}
initial={{ scale, y: yOffset, x: 0 }}
animate={{ scale, y: yOffset, x: 0 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
className={`absolute inset-0 rounded-3xl origin-bottom ${isTop ? 'cursor-grab active:cursor-grabbing touch-pan-y' : ''}`}
style={{ x, rotate, zIndex: 100 - index, boxShadow: 'var(--shadow-float)' }}
drag={isTop ? 'x' : false}
dragSnapToOrigin
dragElastic={0.9}
onDragEnd={onDragEnd}
initial={{ scale, y }}
animate={{ scale, y }}
exit={{ opacity: 0, scale: 0.92, transition: { duration: 0.18 } }}
aria-hidden={!isTop}
>
{/* Tinder-style stamps */}
<motion.div style={{ opacity: opacityRead }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-nature/15">
<div className="border-4 border-nature text-nature font-black text-3xl uppercase tracking-widest px-4 py-2 rounded-lg -rotate-12 bg-carbon-light/40 backdrop-blur-sm">
J'ai lu
</div>
</motion.div>
<motion.div style={{ opacity: opacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-earth/15">
<div className="border-4 border-earth text-earth font-black text-3xl uppercase tracking-widest px-4 py-2 rounded-lg rotate-12 bg-carbon-light/40 backdrop-blur-sm">
Garder
</div>
</motion.div>
{isTop && (
<>
<motion.div style={{ opacity: opacityRead }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-nature/15">
<div className="border-4 border-nature text-nature font-black text-3xl uppercase tracking-widest px-4 py-2 rounded-lg -rotate-12 bg-carbon-light/80">
Lu
</div>
</motion.div>
<motion.div style={{ opacity: opacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-earth/15">
<div className="border-4 border-earth text-earth font-black text-3xl uppercase tracking-widest px-4 py-2 rounded-lg rotate-12 bg-carbon-light/80">
Garder
</div>
</motion.div>
</>
)}
<FocusCard article={article} isTop={isTop} />
</motion.div>
);
}
export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, onEmpty }: FocusCardStackProps) {
export function FocusCardStack({ articles, onEmpty, onExit }: FocusCardStackProps) {
const { setRead } = useArticleActions();
const [currentIndex, setCurrentIndex] = useState(0);
const [readingArticle, setReadingArticle] = useState<Article | null>(null);
const isMobile = useIsMobile();
const [isProcessing, setIsProcessing] = useState(false);
const hasDraggedRef = useRef(false);
const [reading, setReading] = useState(false);
// History of actions so "undo" also restores the unread state on the server.
const history = useRef<Action[]>([]);
const [announce, setAnnounce] = useState('');
const visibleArticles = articles.slice(currentIndex, currentIndex + 3);
const topArticle = visibleArticles[0];
const top = articles[currentIndex];
const visible = articles.slice(currentIndex, currentIndex + 3);
const handleAction = (action: 'read' | 'keep' | 'undo') => {
if (isProcessing) return;
setIsProcessing(true);
const act = useCallback(
(action: Action) => {
const a = articles[currentIndex];
if (!a) return;
if (action === 'read') setRead(a, true);
history.current.push(action);
setAnnounce(action === 'read' ? `« ${a.title} » marqué comme lu` : `« ${a.title} » gardé pour plus tard`);
setCurrentIndex((i) => i + 1);
},
[articles, currentIndex, setRead],
);
if (action === 'undo') {
setCurrentIndex((prev) => Math.max(0, prev - 1));
} else if (action === 'read') {
if (topArticle) onMarkRead(topArticle.id);
setCurrentIndex((prev) => prev + 1);
} else {
if (topArticle) onKeep(topArticle.id);
setCurrentIndex((prev) => prev + 1);
}
setTimeout(() => setIsProcessing(false), 450);
};
const undo = useCallback(() => {
if (currentIndex === 0) return;
const last = history.current.pop();
const prev = articles[currentIndex - 1];
if (last === 'read' && prev) setRead(prev, false);
setAnnounce('Action annulée');
setCurrentIndex((i) => Math.max(0, i - 1));
}, [articles, currentIndex, setRead]);
useEffect(() => {
if (currentIndex >= articles.length && articles.length > 0) onEmpty();
}, [currentIndex, articles.length, onEmpty]);
if (!topArticle) return null;
// Keyboard: ← keep, → read, Entrée/o read it, u/Backspace undo, Échap quit.
useEffect(() => {
if (reading) return;
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
switch (e.key) {
case 'ArrowRight':
act('read');
break;
case 'ArrowLeft':
act('keep');
break;
case 'Enter':
case 'o':
if ((e.target as HTMLElement).tagName === 'BUTTON') return;
setReading(true);
break;
case 'u':
case 'Backspace':
undo();
break;
case 'Escape':
onExit?.();
break;
default:
return;
}
e.preventDefault();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [reading, act, undo, onExit]);
if (!top) return null;
return (
<div className="relative w-full h-full flex items-center justify-center overflow-hidden">
<AnimatePresence>
{readingArticle && (
isMobile ? (
<MobileReaderView
article={readingArticle}
onClose={() => setReadingArticle(null)}
onToggleFavorite={onToggleFavorite}
onNext={() => { handleAction('read'); setReadingArticle(null); }}
onPrev={() => { handleAction('keep'); setReadingArticle(null); }}
/>
) : (
<ReaderView article={readingArticle} onClose={() => setReadingArticle(null)} onToggleFavorite={onToggleFavorite} />
)
)}
</AnimatePresence>
<div className="relative w-full h-full flex flex-col items-center justify-center gap-6">
<p className="sr-only" aria-live="polite">{announce}</p>
<span className="inline-block bg-nature-dark/80 px-4 py-2 rounded-full text-white text-[11px] uppercase font-bold tracking-widest">
{articles.length - currentIndex} restant{articles.length - currentIndex > 1 ? 's' : ''}
</span>
{/* Stack */}
<div className="relative w-full max-w-lg aspect-[3/4] sm:aspect-[4/5] md:h-[600px] z-10">
<AnimatePresence mode="popLayout">
{visibleArticles.map((article, i) => (
<SwipeableCard
key={article.id}
article={article}
index={i}
isTop={i === 0}
onAction={handleAction}
onOpen={() => setReadingArticle(article)}
hasDraggedRef={hasDraggedRef}
isProcessing={isProcessing}
/>
<div className="relative w-full max-w-md flex-1 max-h-[560px] min-h-[340px] mb-8">
<AnimatePresence mode="popLayout" initial={false}>
{visible.map((article, i) => (
<SwipeableCard key={article.id} article={article} index={i} isTop={i === 0} onAction={act} />
))}
</AnimatePresence>
</div>
{/* Controls */}
<div className="absolute bottom-10 left-0 right-0 flex justify-center items-center gap-4 sm:gap-8 px-4 z-20">
{/* Controls (every gesture has a button equivalent) */}
<div className="flex justify-center items-center gap-3 sm:gap-5 pb-4">
<button
onClick={() => handleAction('keep')}
disabled={isProcessing}
className="p-4 rounded-full bg-carbon-light border border-earth/20 text-earth shadow-lg hover:bg-earth hover:text-white hover:scale-110 transition-all disabled:opacity-50 disabled:scale-100"
title="Garder (gauche)" aria-label="Garder"
onClick={() => act('keep')}
className="w-14 h-14 rounded-full bg-carbon-light border border-earth/30 text-earth shadow-lg hover:bg-earth hover:text-on-earth transition-colors flex items-center justify-center"
title="Garder pour plus tard (←)"
aria-label="Garder pour plus tard"
>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => handleAction('undo')}
disabled={currentIndex === 0 || isProcessing}
className={`p-3 rounded-full border shadow-lg transition-all ${
currentIndex > 0
? 'bg-carbon-light border-nature/15 text-nature hover:bg-nature hover:text-white hover:scale-105 opacity-100'
: 'opacity-0 pointer-events-none scale-90'
}`}
title="Annuler" aria-label="Annuler"
onClick={undo}
disabled={currentIndex === 0}
className="icon-btn bg-carbon-light shadow"
title="Annuler (u)"
aria-label="Annuler la dernière action"
>
<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} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button onClick={() => setReading(true)} className="btn-secondary bg-carbon-light" title="Lire l’article (Entrée)">
Lire
</button>
<button
onClick={() => handleAction('read')}
disabled={isProcessing}
className="p-4 rounded-full bg-nature text-white border border-nature shadow-lg shadow-nature/20 hover:bg-nature-light hover:scale-110 transition-all disabled:opacity-50 disabled:scale-100"
title="J'ai lu (droite)" aria-label="J'ai lu"
onClick={() => act('read')}
className="w-14 h-14 rounded-full bg-nature text-on-nature shadow-lg hover:bg-nature-light transition-colors flex items-center justify-center"
title="Marquer comme lu (→)"
aria-label="Marquer comme lu"
>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
{/* Counter */}
<div className="absolute top-10 left-0 right-0 text-center z-20 pointer-events-none">
<span className="inline-block bg-nature-dark/70 backdrop-blur-md px-4 py-2 rounded-full text-white text-[10px] uppercase font-bold tracking-widest">
{articles.length - currentIndex} restants
</span>
</div>
{reading && (
<Reader
key={top.id}
article={top}
next={articles[currentIndex + 1]}
hasPrev={false}
onClose={() => setReading(false)}
onNext={() => {
act('read');
if (!articles[currentIndex + 1]) setReading(false);
}}
/>
)}
</div>
);
}
+6 -13
View File
@@ -1,32 +1,25 @@
import { motion } from 'framer-motion';
interface FocusEmptyStateProps {
onBack: () => void;
}
export function FocusEmptyState({ onBack }: FocusEmptyStateProps) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className="flex flex-col items-center justify-center h-full text-center space-y-8 px-6"
>
<div className="flex-1 flex flex-col items-center justify-center text-center space-y-8 px-6 animate-rise">
<div className="relative">
<div className="w-28 h-28 rounded-full bg-nature/10 flex items-center justify-center animate-bounce-slow">
<span className="text-5xl">🌿</span>
<span className="text-5xl" aria-hidden="true">🌿</span>
</div>
<div className="absolute -top-1 -right-1 w-9 h-9 bg-nature text-white rounded-full flex items-center justify-center font-bold border-4 border-carbon">
<div className="absolute -top-1 -right-1 w-9 h-9 bg-nature text-on-nature rounded-full flex items-center justify-center font-bold border-4 border-carbon" aria-hidden="true">
✓
</div>
</div>
<div className="space-y-2">
<h1 className="text-4xl font-serif italic text-paper-white">C'est tout pour le moment !</h1>
<p className="eyebrow text-paper-muted">Vous êtes à jour sur vos lectures.</p>
<p className="text-paper-muted">Vous êtes à jour sur vos lectures.</p>
</div>
<button onClick={onBack} className="btn-primary py-3 px-8">Retour au tableau de bord</button>
</motion.div>
<button onClick={onBack} className="btn-primary py-3 px-8" autoFocus>Retour au tableau de bord</button>
</div>
);
}
+420
View File
@@ -0,0 +1,420 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSwipeable } from 'react-swipeable';
import { articlesApi, type Article } from '../../api/articles';
import { patchArticle, useArticleActions } from '../../hooks/useArticleActions';
import { usePrefs } from '../../stores/prefsStore';
import { formatLong, readingTimeLabel } from '../../lib/format';
import { prepareArticleHtml } from '../../lib/html';
import { ShareButton } from '../ShareButton';
import { ReaderSettings } from './ReaderSettings';
interface ReaderProps {
/** The list item being read (title, image… shown instantly while the body loads). */
article: Article;
next?: Article;
hasPrev?: boolean;
onClose: () => void;
onNext?: () => void;
onPrev?: () => void;
}
const MEASURES = { narrow: '58ch', medium: '66ch', wide: '76ch' } as const;
const FONTS = {
serif: 'var(--font-reading)',
sans: 'var(--font-sans)',
legible: 'var(--font-legible)',
} as const;
function isTypingTarget(t: EventTarget | null) {
const el = t as HTMLElement | null;
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
}
/**
* Full-screen, distraction-free reader used on desktop and mobile.
* Rendered in a portal; the app behind it is made inert while it's open.
*/
export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, onPrev }: ReaderProps) {
const qc = useQueryClient();
const { toggleRead, toggleFavorite } = useArticleActions();
const fontSize = usePrefs((s) => s.fontSize);
const lineHeight = usePrefs((s) => s.lineHeight);
const width = usePrefs((s) => s.width);
const font = usePrefs((s) => s.font);
const setFontSize = usePrefs((s) => s.setFontSize);
const scrollRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [summarizing, setSummarizing] = useState(false);
const [summaryError, setSummaryError] = useState('');
const { data: detail, isError, refetch } = useQuery({
queryKey: ['article', listArticle.id],
queryFn: () => articlesApi.get(listArticle.id),
staleTime: 5 * 60 * 1000,
});
// Live flags (read/favorite/summary) come from the list cache, content from the detail.
const article = { ...detail, ...listArticle, ai_summary: listArticle.ai_summary || detail?.ai_summary };
const html = useMemo(
() => prepareArticleHtml(detail?.content || detail?.summary || '', listArticle.image_url),
[detail?.content, detail?.summary, listArticle.image_url],
);
// Prefetch the next article so "next" is instant.
useEffect(() => {
if (!next) return;
const t = window.setTimeout(() => {
qc.prefetchQuery({ queryKey: ['article', next.id], queryFn: () => articlesApi.get(next.id), staleTime: 5 * 60 * 1000 });
if (next.image_url) new Image().src = next.image_url;
}, 600);
return () => window.clearTimeout(t);
}, [next, qc]);
// Modal behaviour: make the app inert, move focus in, restore it on close.
useLayoutEffect(() => {
const root = document.getElementById('root');
const previous = document.activeElement as HTMLElement | null;
root?.setAttribute('inert', '');
document.body.style.overflow = 'hidden';
return () => {
root?.removeAttribute('inert');
document.body.style.overflow = '';
previous?.focus?.({ preventScroll: true });
};
}, []);
// New article: back to top, focus the title.
useLayoutEffect(() => {
scrollRef.current?.scrollTo({ top: 0 });
titleRef.current?.focus({ preventScroll: true });
setSummaryError('');
}, [listArticle.id]);
// Reading progress without React re-renders.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
let frame = 0;
const update = () => {
frame = 0;
const max = el.scrollHeight - el.clientHeight;
const ratio = max > 0 ? Math.min(1, el.scrollTop / max) : 1;
if (progressRef.current) {
progressRef.current.style.transform = `scaleX(${ratio})`;
progressRef.current.setAttribute('aria-valuenow', String(Math.round(ratio * 100)));
}
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
cancelAnimationFrame(frame);
};
}, [html]);
const openOriginal = useCallback(() => {
if (listArticle.url) window.open(listArticle.url, '_blank', 'noopener,noreferrer');
}, [listArticle.url]);
// Keyboard shortcuts (Miniflux / NetNewsWire conventions).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return;
const el = scrollRef.current;
switch (e.key) {
case 'Escape':
onClose();
break;
case 'j':
case 'n':
case 'ArrowRight':
if (onNext) onNext();
break;
case 'k':
case 'p':
case 'ArrowLeft':
if (onPrev && hasPrev) onPrev();
break;
case 's':
case 'f':
toggleFavorite(listArticle);
break;
case 'm':
toggleRead(listArticle);
break;
case 'v':
case 'o':
openOriginal();
break;
case '+':
case '=':
setFontSize(fontSize + 1);
break;
case '-':
setFontSize(fontSize - 1);
break;
case ' ': {
// Space pages down; at the end it moves to the next article.
if (!el || e.shiftKey) return;
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
if (atEnd && onNext) {
e.preventDefault();
onNext();
}
return;
}
default:
return;
}
e.preventDefault();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, onNext, onPrev, hasPrev, toggleFavorite, toggleRead, listArticle, openOriginal, fontSize, setFontSize]);
// Touch swipe between articles; ignored over horizontally scrollable content.
const swipe = useSwipeable({
onSwipedLeft: (ev) => {
if (!(ev.event.target as Element).closest('pre,table,[data-noswipe]') && onNext) onNext();
},
onSwipedRight: (ev) => {
if (!(ev.event.target as Element).closest('pre,table,[data-noswipe]') && hasPrev && onPrev) onPrev();
},
trackMouse: false,
delta: 80,
swipeDuration: 500,
preventScrollOnSwipe: false,
});
const summarize = async () => {
setSummarizing(true);
setSummaryError('');
try {
const res = await articlesApi.summarize(listArticle.id);
patchArticle(qc, listArticle.id, { ai_summary: res.summary });
} catch {
setSummaryError('Le résumé n’a pas pu être généré. Réessayez plus tard.');
} finally {
setSummarizing(false);
}
};
const style = {
'--reader-size': `${fontSize}px`,
'--reader-leading': String(lineHeight),
'--reader-font': FONTS[font],
letterSpacing: font === 'legible' ? '0.02em' : undefined,
} as React.CSSProperties;
const measure = MEASURES[width];
const body = (
<div
ref={scrollRef}
className="fixed inset-0 z-50 overflow-y-auto overscroll-contain bg-carbon animate-fade-in"
role="dialog"
aria-modal="true"
aria-labelledby="reader-title"
>
{/* Progress */}
<div className="sticky top-0 z-30 h-0.5 w-full bg-transparent">
<div
ref={progressRef}
className="h-full bg-nature origin-left will-change-transform"
style={{ transform: 'scaleX(0)' }}
role="progressbar"
aria-label="Progression de lecture"
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
{/* Toolbar */}
<div className="sticky top-0.5 z-20 bg-carbon/90 backdrop-blur-md border-b border-paper-muted/10">
<div className="mx-auto max-w-5xl flex items-center gap-1.5 px-3 sm:px-6 py-2">
<button onClick={onClose} className="icon-btn" aria-label="Fermer (Échap)" title="Fermer (Échap)">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="hidden sm:flex items-center gap-1.5">
<button onClick={onPrev} disabled={!hasPrev} className="icon-btn" aria-label="Article précédent (k)" title="Précédent (k)">
<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="M5 15l7-7 7 7" />
</svg>
</button>
<button onClick={onNext} disabled={!onNext} className="icon-btn" aria-label="Article suivant (j)" title="Suivant (j)">
<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="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<span className="flex-1" />
<button
onClick={() => toggleRead(listArticle)}
className="icon-btn"
aria-pressed={!article.is_read}
aria-label={article.is_read ? 'Marquer comme non lu (m)' : 'Marquer comme lu (m)'}
title={article.is_read ? 'Marquer non lu (m)' : 'Marquer lu (m)'}
>
<svg className="w-4 h-4" fill={article.is_read ? 'none' : 'currentColor'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<circle cx="12" cy="12" r="5" strokeWidth={1.8} />
</svg>
</button>
<button
onClick={() => toggleFavorite(listArticle)}
className={`icon-btn ${article.is_favorite ? '!bg-earth !text-on-earth !border-earth' : ''}`}
aria-pressed={article.is_favorite}
aria-label="Favori (s)"
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
>
<svg className="w-4 h-4" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<ShareButton article={article} />
<div className="relative">
<button
onClick={() => setSettingsOpen((o) => !o)}
className="icon-btn font-serif text-base"
aria-expanded={settingsOpen}
aria-label="Réglages de lecture"
title="Réglages de lecture"
>
Aa
</button>
{settingsOpen && <ReaderSettings onClose={() => setSettingsOpen(false)} />}
</div>
{listArticle.url && (
<a href={listArticle.url} target="_blank" rel="noopener noreferrer" className="icon-btn" aria-label="Ouvrir l'article original (v)" title="Original (v)">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
</div>
<article {...swipe} className="mx-auto max-w-5xl pb-28" style={style}>
{listArticle.image_url && (
<div className="px-0 sm:px-6 pt-0 sm:pt-6">
<img
src={listArticle.image_url}
alt=""
fetchPriority="high"
decoding="async"
referrerPolicy="no-referrer"
className="w-full max-h-[46vh] object-cover sm:rounded-3xl bg-carbon-dark"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
</div>
)}
<div className="px-5 sm:px-6 mx-auto" style={{ maxWidth: measure }}>
<header className="pt-8 pb-6">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mb-5 text-sm text-paper-muted">
<span className="chip">{listArticle.feed_title || 'Journal'}</span>
<time dateTime={listArticle.published_at || listArticle.created_at}>
{formatLong(listArticle.published_at || listArticle.created_at)}
</time>
<span aria-hidden="true">·</span>
<span>{readingTimeLabel(listArticle.reading_time)} de lecture</span>
</div>
<h1
id="reader-title"
ref={titleRef}
tabIndex={-1}
className="font-serif text-paper-white leading-[1.15] tracking-tight text-balance outline-none"
style={{ fontSize: 'clamp(1.9rem, 1.3rem + 2.4vw, 3rem)' }}
>
{listArticle.title}
</h1>
{listArticle.author && <p className="mt-4 text-paper-muted text-sm">Par {listArticle.author}</p>}
{/* AI digest */}
<div className="mt-7">
{article.ai_summary ? (
<aside className="bg-nature/8 border-l-4 border-nature p-5 rounded-r-2xl" aria-label="Résumé IA">
<h2 className="eyebrow mb-2">Résumé IA</h2>
<p className="text-paper-white/90 leading-relaxed font-reading">{article.ai_summary}</p>
</aside>
) : (
<>
<button onClick={summarize} disabled={summarizing} className="btn-secondary">
{summarizing ? (
<span className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" aria-hidden="true" />
) : (
<span aria-hidden="true">✨</span>
)}
{summarizing ? 'Génération…' : 'Résumer avec l’IA'}
</button>
{summaryError && <p role="alert" className="mt-2 text-sm text-danger">{summaryError}</p>}
</>
)}
</div>
</header>
{detail ? (
html ? (
<div className={`magazine-content ${font === 'serif' ? 'drop-cap' : ''}`} dangerouslySetInnerHTML={{ __html: html }} />
) : (
<p className="text-paper-muted italic">
Ce flux ne publie qu’un titre. {listArticle.url && 'Ouvrez l’article original pour le lire en entier.'}
</p>
)
) : isError ? (
<div role="alert" className="text-paper-muted">
Impossible de charger l’article.{' '}
<button className="text-nature underline" onClick={() => refetch()}>Réessayer</button>
</div>
) : (
<div className="space-y-4" aria-busy="true" aria-label="Chargement de l'article">
{[92, 100, 85, 97, 60].map((w, i) => (
<div key={i} className="h-4 rounded bg-carbon-dark animate-pulse" style={{ width: `${w}%` }} />
))}
</div>
)}
{listArticle.url && (
<p className="mt-12">
<a href={listArticle.url} target="_blank" rel="noopener noreferrer" className="btn-secondary">
Lire sur le site d’origine
</a>
</p>
)}
{/* Up next */}
{next && onNext && (
<button
onClick={onNext}
className="mt-14 w-full text-left surface-card p-5 flex items-center gap-4 hover:border-nature/40 transition-colors group"
>
<span className="flex-1 min-w-0">
<span className="eyebrow block mb-1">À suivre</span>
<span className="block font-serif text-lg text-paper-white leading-snug line-clamp-2 group-hover:text-nature">
{next.title}
</span>
<span className="block mt-1 text-xs text-paper-muted">
{next.feed_title} · {readingTimeLabel(next.reading_time)}
</span>
</span>
<span className="kbd hidden sm:inline-flex" aria-hidden="true">Espace</span>
</button>
)}
</div>
</article>
</div>
);
return createPortal(body, document.body);
}
@@ -0,0 +1,161 @@
import { useEffect, useRef } from 'react';
import {
usePrefs,
FONT_SIZE_MAX,
FONT_SIZE_MIN,
type ReaderFont,
type ReaderWidth,
type ThemePref,
} from '../../stores/prefsStore';
interface ReaderSettingsProps {
onClose: () => void;
}
const THEMES: { value: ThemePref; label: string; swatch: string }[] = [
{ value: 'light', label: 'Clair', swatch: '#FFFFFF' },
{ value: 'sepia', label: 'Sépia', swatch: '#F4ECD8' },
{ value: 'dark', label: 'Sombre', swatch: '#161D17' },
{ value: 'system', label: 'Auto', swatch: 'linear-gradient(135deg,#fff 50%,#161D17 50%)' },
];
const FONTS: { value: ReaderFont; label: string; family: string }[] = [
{ value: 'serif', label: 'Serif', family: 'var(--font-reading)' },
{ value: 'sans', label: 'Sans', family: 'var(--font-sans)' },
{ value: 'legible', label: 'Lisible', family: 'var(--font-legible)' },
];
const WIDTHS: { value: ReaderWidth; label: string }[] = [
{ value: 'narrow', label: 'Étroite' },
{ value: 'medium', label: 'Moyenne' },
{ value: 'wide', label: 'Large' },
];
const LEADINGS = [
{ value: 1.5, label: 'Serré' },
{ value: 1.65, label: 'Normal' },
{ value: 1.85, label: 'Aéré' },
];
/** "Aa" popover: theme, font, size, line height and line length. */
export function ReaderSettings({ onClose }: ReaderSettingsProps) {
const prefs = usePrefs();
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
panelRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopImmediatePropagation();
onClose();
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
return (
<>
<div className="fixed inset-0 z-[60]" onClick={onClose} aria-hidden="true" />
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-label="Réglages de lecture"
className="absolute right-0 top-14 z-[61] w-[min(22rem,calc(100vw-2rem))] surface-card p-5 space-y-5 animate-fade-in text-paper-white"
>
<fieldset>
<legend className="eyebrow text-paper-muted mb-2">Thème</legend>
<div className="grid grid-cols-4 gap-2">
{THEMES.map((t) => (
<button
key={t.value}
onClick={() => prefs.setTheme(t.value)}
aria-pressed={prefs.theme === t.value}
className={`flex flex-col items-center gap-1.5 rounded-xl p-2 text-xs font-medium border transition-colors ${
prefs.theme === t.value ? 'border-nature text-nature' : 'border-paper-muted/20 text-paper-muted hover:text-paper-white'
}`}
>
<span className="w-7 h-7 rounded-full border border-paper-muted/30" style={{ background: t.swatch }} />
{t.label}
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="eyebrow text-paper-muted mb-2">Police</legend>
<div className="segmented w-full">
{FONTS.map((f) => (
<button
key={f.value}
className="flex-1 !text-sm"
style={{ fontFamily: f.family }}
aria-pressed={prefs.font === f.value}
onClick={() => prefs.setFont(f.value)}
>
{f.label}
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="eyebrow text-paper-muted mb-2">Taille du texte</legend>
<div className="flex items-center gap-3">
<button
className="icon-btn"
onClick={() => prefs.setFontSize(prefs.fontSize - 1)}
disabled={prefs.fontSize <= FONT_SIZE_MIN}
aria-label="Réduire le texte"
>
<span className="text-sm font-serif">A</span>
</button>
<input
type="range"
min={FONT_SIZE_MIN}
max={FONT_SIZE_MAX}
value={prefs.fontSize}
onChange={(e) => prefs.setFontSize(Number(e.target.value))}
className="flex-1 accent-[var(--color-nature)]"
aria-label="Taille du texte"
aria-valuetext={`${prefs.fontSize} pixels`}
/>
<button
className="icon-btn"
onClick={() => prefs.setFontSize(prefs.fontSize + 1)}
disabled={prefs.fontSize >= FONT_SIZE_MAX}
aria-label="Agrandir le texte"
>
<span className="text-xl font-serif">A</span>
</button>
</div>
</fieldset>
<fieldset>
<legend className="eyebrow text-paper-muted mb-2">Interligne</legend>
<div className="segmented w-full">
{LEADINGS.map((l) => (
<button key={l.value} className="flex-1" aria-pressed={prefs.lineHeight === l.value} onClick={() => prefs.setLineHeight(l.value)}>
{l.label}
</button>
))}
</div>
</fieldset>
<fieldset className="hidden sm:block">
<legend className="eyebrow text-paper-muted mb-2">Largeur de colonne</legend>
<div className="segmented w-full">
{WIDTHS.map((w) => (
<button key={w.value} className="flex-1" aria-pressed={prefs.width === w.value} onClick={() => prefs.setWidth(w.value)}>
{w.label}
</button>
))}
</div>
</fieldset>
<button className="btn-ghost w-full" onClick={prefs.resetReader}>Réinitialiser</button>
</div>
</>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient, type InfiniteData, type QueryClient } from '@tanstack/react-query';
import { articlesApi, type Article, type ArticleDetail } from '../api/articles';
import type { Feed } from '../api/feeds';
type Pages = InfiniteData<Article[], unknown>;
type ListCache = Pages | Article[] | undefined;
/**
* Applies a partial update to an article everywhere it is cached (every list
* page and the detail entry) without refetching anything.
*/
export function patchArticle(qc: QueryClient, id: string, patch: Partial<ArticleDetail>) {
qc.setQueriesData<ListCache>({ queryKey: ['articles'] }, (old) => {
if (Array.isArray(old)) {
return old.some((a) => a.id === id) ? old.map((a) => (a.id === id ? { ...a, ...patch } : a)) : old;
}
if (!old?.pages) return old;
let changed = false;
const pages = old.pages.map((page) =>
page.map((a) => {
if (a.id !== id) return a;
changed = true;
return { ...a, ...patch };
}),
);
return changed ? { ...old, pages } : old;
});
qc.setQueryData<ArticleDetail>(['article', id], (old) => (old ? { ...old, ...patch } : old));
}
/** Finds an article in any cached list or detail entry. */
export function findCachedArticle(qc: QueryClient, id: string): Article | undefined {
const detail = qc.getQueryData<ArticleDetail>(['article', id]);
if (detail) return detail;
for (const [, data] of qc.getQueriesData<ListCache>({ queryKey: ['articles'] })) {
const pages = Array.isArray(data) ? [data] : (data?.pages ?? []);
for (const page of pages) {
const hit = page.find((a) => a.id === id);
if (hit) return hit;
}
}
return undefined;
}
/** Adjusts the cached unread counter of a feed (sidebar badges). */
function bumpUnread(qc: QueryClient, feedId: string, delta: number) {
qc.setQueryData<Feed[]>(['feeds'], (old) =>
old?.map((f) => (f.id === feedId ? { ...f, unread_count: Math.max(0, (f.unread_count ?? 0) + delta) } : f)),
);
}
/** Read / favorite actions with optimistic cache updates and rollback on error. */
export function useArticleActions() {
const qc = useQueryClient();
const readMutation = useMutation({
mutationFn: ({ id, read }: { id: string; read: boolean; feedId: string }) =>
read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
onMutate: ({ id, read, feedId }) => {
const prev = findCachedArticle(qc, id);
if (prev && prev.is_read === read) return { skipped: true };
patchArticle(qc, id, { is_read: read });
bumpUnread(qc, feedId, read ? -1 : 1);
return { skipped: false };
},
onError: (_e, { id, read, feedId }, ctx) => {
if (ctx?.skipped) return;
patchArticle(qc, id, { is_read: !read });
bumpUnread(qc, feedId, read ? 1 : -1);
},
});
const favoriteMutation = useMutation({
mutationFn: ({ id }: { id: string; next: boolean }) => articlesApi.toggleFavorite(id),
onMutate: ({ id, next }) => patchArticle(qc, id, { is_favorite: next }),
onSuccess: (res, { id }) => {
patchArticle(qc, id, { is_favorite: res.is_favorite });
// The favorites list itself must gain/lose the item.
qc.invalidateQueries({ queryKey: ['articles', { favorites: true }], refetchType: 'none' });
},
onError: (_e, { id, next }) => patchArticle(qc, id, { is_favorite: !next }),
});
const { mutate: mutateRead } = readMutation;
const { mutate: mutateFavorite } = favoriteMutation;
const setRead = useCallback(
(a: Pick<Article, 'id' | 'feed_id'>, read: boolean) => mutateRead({ id: a.id, read, feedId: a.feed_id }),
[mutateRead],
);
const toggleRead = useCallback(
(a: Pick<Article, 'id' | 'feed_id' | 'is_read'>) => mutateRead({ id: a.id, read: !a.is_read, feedId: a.feed_id }),
[mutateRead],
);
const toggleFavorite = useCallback(
(a: Pick<Article, 'id' | 'is_favorite'>) => mutateFavorite({ id: a.id, next: !a.is_favorite }),
[mutateFavorite],
);
return useMemo(() => ({ setRead, toggleRead, toggleFavorite }), [setRead, toggleRead, toggleFavorite]);
}
+14 -10
View File
@@ -1,14 +1,18 @@
import { useState, useEffect } from 'react';
import { useSyncExternalStore } from 'react';
const query = '(max-width: 768px)';
function subscribe(cb: () => void) {
const media = window.matchMedia(query);
media.addEventListener('change', cb);
return () => media.removeEventListener('change', cb);
}
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches);
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
}
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;
/** True when the user asked the OS to minimise motion. */
export function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
+81 -34
View File
@@ -1,51 +1,98 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { create } from 'zustand';
import { patchArticle } from './useArticleActions';
interface LiveState {
/** Articles fetched since the list was last loaded (not yet shown). */
newCount: number;
addNew: (n: number) => void;
clearNew: () => void;
}
export const useLive = create<LiveState>((set) => ({
newCount: 0,
addNew: (n) => set((s) => ({ newCount: s.newCount + n })),
clearNew: () => set({ newCount: 0 }),
}));
interface WsEvent {
type: string;
payload?: Record<string, unknown>;
}
/**
* Keeps one WebSocket per session. Server events only concern the current
* user; read/favorite/summary updates patch the cache in place, new articles
* are announced (not auto-inserted) so the list never jumps while reading.
*/
export function useWebsocket() {
const queryClient = useQueryClient();
const socketRef = useRef<WebSocket | null>(null);
const qc = useQueryClient();
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/api/v1/ws`;
let socket: WebSocket | null = null;
let retry = 0;
let retryTimer: number | undefined;
let feedsTimer: number | undefined;
let closed = false;
const refreshFeedsSoon = () => {
window.clearTimeout(feedsTimer);
feedsTimer = window.setTimeout(() => qc.invalidateQueries({ queryKey: ['feeds'] }), 1500);
};
const onMessage = (event: MessageEvent) => {
let msg: WsEvent;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
const p = msg.payload ?? {};
switch (msg.type) {
case 'article_updated': {
const { id, ...rest } = p as { id: string } & Record<string, unknown>;
if (id) patchArticle(qc, id, rest);
if ('is_read' in rest) refreshFeedsSoon();
break;
}
case 'new_articles':
useLive.getState().addNew(Number(p.count) || 0);
refreshFeedsSoon();
break;
case 'articles_bulk_read':
qc.invalidateQueries({ queryKey: ['articles'] });
refreshFeedsSoon();
break;
case 'refresh_done':
refreshFeedsSoon();
break;
}
};
const connect = () => {
const socket = new WebSocket(wsUrl);
socketRef.current = socket;
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('WS Message:', data);
if (data.type === 'new_articles' || data.type === 'article_updated') {
// Invalidate articles and feeds query to trigger refetch
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['feeds'] });
}
} catch (err) {
console.error('Failed to parse WS message', err);
}
if (closed) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(`${protocol}//${window.location.host}/api/v1/ws`);
socket.onopen = () => {
retry = 0;
};
socket.onmessage = onMessage;
socket.onclose = () => {
console.log('WS connection closed, retrying in 5s...');
setTimeout(connect, 5000);
};
socket.onerror = (err) => {
console.error('WS error:', err);
socket.close();
if (closed) return;
const delay = Math.min(30_000, 1000 * 2 ** retry++);
retryTimer = window.setTimeout(connect, delay);
};
socket.onerror = () => socket?.close();
};
connect();
return () => {
if (socketRef.current) {
socketRef.current.close();
}
closed = true;
window.clearTimeout(retryTimer);
window.clearTimeout(feedsTimer);
socket?.close();
};
}, [queryClient]);
}, [qc]);
}
+207 -96
View File
@@ -1,11 +1,13 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap');
@import "tailwindcss";
/* ============================================================
FlowReader — Design System "Éditorial Botanique"
Light: Organic Warmth · Dark: Deep Forest
Light: Organic Warmth · Sepia: Paper · Dark: Deep Forest
Fonts are self-hosted (see main.tsx) — no third-party requests.
============================================================ */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Surfaces — warm paper */
--color-carbon: #F7F4EC; /* app background */
@@ -16,23 +18,26 @@
--color-nature: #15643A;
--color-nature-light: #1E8A4F;
--color-nature-dark: #0A2E1B;
--color-on-nature: #FFFFFF; /* text on a nature-filled surface */
/* Accent — terracotta (used sparingly) */
--color-earth: #B45B3E;
--color-earth: #A9502F;
--color-earth-light: #C9714F;
--color-on-earth: #FFFFFF;
/* Ink */
--color-paper-white: #1B1714; /* primary text */
--color-paper-muted: #6B645B; /* secondary text */
--color-paper-muted: #635C53; /* secondary text (≥ 5:1 on paper) */
/* Semantic */
--color-ring: #1E8A4F;
--color-danger: #C2410C;
--color-danger: #B3390A;
/* Typography */
--font-serif: "Playfair Display", Georgia, serif;
--font-sans: "Inter", system-ui, sans-serif;
--font-reading: "Lora", Georgia, serif;
--font-serif: "Playfair Display Variable", "Playfair Display", Georgia, serif;
--font-sans: "Inter Variable", "Inter", system-ui, sans-serif;
--font-reading: "Lora Variable", "Lora", Georgia, serif;
--font-legible: "Atkinson Hyperlegible", "Inter Variable", system-ui, sans-serif;
/* Radii scale */
--radius-xs: 0.375rem;
@@ -41,29 +46,47 @@
--radius-lg: 1.25rem;
--radius-xl: 1.75rem;
--line-height-magazine: 1.85;
/* Shadows */
--shadow-soft: 0 2px 8px -2px rgba(27, 23, 20, 0.08), 0 4px 24px -6px rgba(27, 23, 20, 0.06);
--shadow-lift: 0 12px 32px -12px rgba(27, 23, 20, 0.18);
--shadow-float: 0 24px 60px -20px rgba(10, 46, 27, 0.28);
}
/* ---------- Dark theme: Deep Forest ---------- */
/* ---------- Sepia theme: long-form paper ---------- */
.sepia {
--color-carbon: #F4ECD8;
--color-carbon-light: #FBF5E6;
--color-carbon-dark: #E6DCC3;
--color-nature: #2D5F34;
--color-nature-light: #3B7A44;
--color-nature-dark: #1C3320;
--color-earth: #9A4A2A;
--color-paper-white: #3B2F24;
--color-paper-muted: #6B5A47;
--color-ring: #3B7A44;
}
/* ---------- Dark theme: Deep Forest (no pure black / pure white) ---------- */
.dark {
--color-carbon: #0A0F0B;
--color-carbon-light: #12190F;
--color-carbon-dark: #060A06;
--color-carbon: #0E130F;
--color-carbon-light: #161D17;
--color-carbon-dark: #090D0A;
--color-nature: #34D27B;
--color-nature-light: #5DE89A;
--color-nature-dark: #0E1C12;
--color-on-nature: #06210F; /* dark ink on bright green: ~9:1 */
--color-earth: #D98060;
--color-earth: #E08B6B;
--color-earth-light: #E59A7E;
--color-on-earth: #2A0F05;
--color-paper-white: #F4F1E8;
--color-paper-muted: #9A958A;
--color-paper-white: #E9E5DA;
--color-paper-muted: #A39E92;
--color-ring: #34D27B;
--color-danger: #F87045;
@@ -76,13 +99,6 @@
/* ============================================================
Custom utilities
============================================================ */
@utility nature-glow {
box-shadow: 0 4px 22px rgba(21, 100, 58, 0.18);
}
.dark .nature-glow {
box-shadow: 0 4px 26px rgba(52, 210, 123, 0.22);
}
@utility paper-panel {
background: var(--color-carbon-light);
border: 1px solid color-mix(in oklab, var(--color-paper-muted) 16%, transparent);
@@ -93,6 +109,16 @@
text-wrap: balance;
}
@utility text-pretty {
text-wrap: pretty;
}
/* Skip rendering work for off-screen cards in long lists. */
@utility cv-auto {
content-visibility: auto;
contain-intrinsic-size: auto 440px;
}
/* ============================================================
Base
============================================================ */
@@ -105,7 +131,6 @@
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
@@ -113,19 +138,9 @@
margin: 0;
min-height: 100vh;
border-top: 3px solid var(--color-nature);
background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 8%, transparent), transparent),
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.14'/%3E%3C/svg%3E");
background-blend-mode: multiply;
background-repeat: no-repeat, repeat;
transition: background-color 0.4s ease, color 0.4s ease;
}
.dark body {
background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 10%, transparent), transparent),
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
background-blend-mode: screen, screen;
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 8%, transparent), transparent);
background-repeat: no-repeat;
transition: background-color 0.3s ease, color 0.3s ease;
}
h1, h2, h3, h4, h5, h6 {
@@ -133,7 +148,7 @@
}
a {
@apply transition-colors duration-300;
@apply transition-colors duration-200;
}
/* Accessible focus — keyboard only */
@@ -174,50 +189,64 @@
@layer components {
/* Eyebrow / kicker label */
.eyebrow {
@apply text-[10px] uppercase tracking-[0.35em] font-bold text-nature;
@apply text-[11px] uppercase tracking-[0.3em] font-bold text-nature;
}
/* Buttons */
.btn-primary {
@apply inline-flex items-center justify-center gap-2 bg-nature text-white font-bold
px-6 py-2.5 rounded-full text-[11px] uppercase tracking-[0.18em]
border border-transparent transition-all duration-300
@apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-bold
min-h-11 px-6 py-2.5 rounded-full text-xs uppercase tracking-[0.16em]
border border-transparent transition-all duration-200
hover:bg-nature-light hover:shadow-[0_8px_24px_-8px_rgba(21,100,58,0.5)]
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
}
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-bold
px-6 py-2.5 rounded-full text-[11px] uppercase tracking-[0.18em]
border border-nature/30 transition-all duration-300
hover:bg-nature hover:text-white hover:border-nature
min-h-11 px-5 py-2.5 rounded-full text-xs uppercase tracking-[0.16em]
border border-nature/35 transition-all duration-200
hover:bg-nature hover:text-on-nature hover:border-nature
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-2 text-paper-muted font-medium
px-4 py-2 rounded-full text-sm transition-all duration-300
min-h-11 px-4 py-2 rounded-full text-sm transition-all duration-200
hover:text-nature hover:bg-nature/5;
}
.icon-btn {
@apply inline-flex items-center justify-center w-9 h-9 rounded-full text-nature
border border-nature/15 transition-all duration-300
hover:bg-nature/10 hover:border-nature/30 active:scale-95;
@apply inline-flex items-center justify-center w-11 h-11 shrink-0 rounded-full text-nature
border border-nature/20 transition-all duration-200
hover:bg-nature/10 hover:border-nature/35 active:scale-95
disabled:opacity-40 disabled:pointer-events-none;
}
/* Segmented control (filters, reader settings) */
.segmented {
@apply inline-flex p-1 gap-1 rounded-full bg-carbon-dark/70 border border-paper-muted/15;
}
.segmented > button {
@apply min-h-9 px-3.5 rounded-full text-xs font-semibold text-paper-muted transition-colors
hover:text-paper-white;
}
.segmented > button[aria-pressed="true"],
.segmented > button[aria-checked="true"] {
@apply bg-carbon-light text-paper-white shadow-sm;
}
/* Inputs */
.input-field {
@apply w-full bg-carbon-light text-paper-white px-4 py-3 rounded-xl
border border-paper-muted/20 shadow-sm transition-all duration-200
placeholder:text-paper-muted/45
border border-paper-muted/25 shadow-sm transition-all duration-200
placeholder:text-paper-muted
focus:border-nature/50 focus:ring-2 focus:ring-nature/25 outline-none;
}
/* Chip / badge */
.chip {
@apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full
text-[10px] font-bold uppercase tracking-[0.18em]
text-[11px] font-bold uppercase tracking-[0.14em]
bg-nature/10 text-nature;
}
@@ -227,44 +256,128 @@
box-shadow: var(--shadow-soft);
}
/* ---------- Magazine reading content ---------- */
.magazine-content {
@apply font-reading text-paper-white/90;
line-height: var(--line-height-magazine);
}
.magazine-content p { @apply mb-7; }
.magazine-content a {
@apply text-nature border-b border-nature/30 hover:border-nature transition-colors no-underline font-medium;
}
.magazine-content h2,
.magazine-content h3 {
@apply text-paper-white font-serif mb-5 mt-12 leading-snug;
}
.magazine-content h2 { @apply text-3xl; }
.magazine-content h3 { @apply text-2xl; }
.magazine-content img {
@apply rounded-2xl my-10 shadow-lg mx-auto max-w-full h-auto;
}
.magazine-content blockquote {
@apply border-l-4 border-nature/40 pl-6 italic text-paper-white/80 my-8;
}
.magazine-content ul,
.magazine-content ol { @apply mb-7 ml-6 space-y-3; }
.magazine-content ul { @apply list-disc; }
.magazine-content ol { @apply list-decimal; }
.magazine-content li { @apply pl-1 marker:text-nature; }
.magazine-content pre {
@apply bg-nature-dark/40 text-paper-white rounded-xl p-4 overflow-x-auto my-8 text-sm;
}
.magazine-content code {
@apply font-mono text-[0.9em] text-earth;
/* Keyboard hint */
.kbd {
@apply inline-flex items-center justify-center min-w-6 h-6 px-1.5 rounded-md border border-paper-muted/30
bg-carbon-light text-[11px] font-mono font-semibold text-paper-white shadow-sm;
}
/* Drop cap for immersive reader */
.drop-cap::first-letter {
@apply font-serif text-nature float-left mr-3 mt-2;
font-size: 4.5rem;
line-height: 3rem;
/* ---------- Article body ----------
Driven by reader preferences via CSS variables set on the reader:
--reader-size, --reader-leading, --reader-font. Values follow
long-form reading guidance (50–75 chars, 1.5–1.7 line-height). */
.magazine-content {
font-family: var(--reader-font, var(--font-reading));
font-size: var(--reader-size, 19px);
line-height: var(--reader-leading, 1.65);
color: color-mix(in oklab, var(--color-paper-white) 92%, transparent);
overflow-wrap: break-word;
font-kerning: normal;
font-variant-ligatures: common-ligatures;
}
.magazine-content > * + * { margin-top: 1.15em; }
.magazine-content p,
.magazine-content li { text-wrap: pretty; }
@media (max-width: 640px) {
.magazine-content { hyphens: auto; }
}
.magazine-content a {
@apply text-nature font-medium;
text-decoration: underline;
text-decoration-color: color-mix(in oklab, currentColor 45%, transparent);
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}
.magazine-content a:hover { text-decoration-color: currentColor; }
.magazine-content h1,
.magazine-content h2,
.magazine-content h3,
.magazine-content h4 {
@apply text-paper-white font-serif leading-snug;
margin-top: 2em;
margin-bottom: 0.6em;
text-wrap: balance;
}
.magazine-content h1 { font-size: 1.6em; }
.magazine-content h2 { font-size: 1.4em; }
.magazine-content h3 { font-size: 1.2em; }
.magazine-content h4 { font-size: 1.05em; @apply font-semibold; }
.magazine-content h5,
.magazine-content h6 {
@apply font-sans font-bold uppercase tracking-wide text-paper-muted;
font-size: 0.8em;
margin-top: 1.8em;
}
.magazine-content h2 + *,
.magazine-content h3 + *,
.magazine-content h4 + * { margin-top: 0; }
.magazine-content img,
.magazine-content video {
@apply rounded-xl mx-auto max-w-full h-auto;
margin-top: 1.6em;
margin-bottom: 1.6em;
}
/* Tracking pixels and spacer GIFs */
.magazine-content img[width="1"],
.magazine-content img[height="1"] { display: none; }
.magazine-content figure { margin: 1.8em 0; }
.magazine-content figure img { margin: 0 auto; }
.magazine-content figcaption {
@apply mt-3 text-sm text-paper-muted font-sans text-center leading-normal;
}
.magazine-content blockquote {
@apply border-l-4 border-nature/40 pl-5 italic text-paper-white/85;
}
.magazine-content ul,
.magazine-content ol { padding-left: 1.4em; }
.magazine-content ul { list-style: disc; }
.magazine-content ol { list-style: decimal; }
.magazine-content li + li { margin-top: 0.4em; }
.magazine-content li { @apply marker:text-nature; }
.magazine-content hr {
@apply border-0 h-px bg-paper-muted/25 mx-auto w-1/3;
margin-top: 2.4em;
margin-bottom: 2.4em;
}
.magazine-content pre {
@apply bg-carbon-dark text-paper-white rounded-xl p-4 overflow-x-auto text-sm leading-relaxed;
}
.magazine-content code {
@apply font-mono text-[0.88em];
}
.magazine-content :not(pre) > code {
@apply bg-carbon-dark/80 px-1.5 py-0.5 rounded text-earth;
}
.magazine-content pre code { color: inherit; font-size: inherit; }
.magazine-content table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
@apply text-[0.85em] font-sans;
}
.magazine-content th,
.magazine-content td {
@apply border border-paper-muted/25 px-3 py-2 text-left align-top;
}
.magazine-content th { @apply bg-carbon-dark font-semibold; }
/* Drop cap on the first paragraph (desktop reader only) */
@media (min-width: 640px) {
.drop-cap > p:first-child::first-letter {
@apply font-serif text-nature float-left mr-3 mt-1;
font-size: 3.6em;
line-height: 0.85;
}
}
}
@@ -272,22 +385,20 @@
Keyframes & motion utilities
============================================================ */
@layer utilities {
.text-shadow-sm { text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.animate-fade-in { animation: fade-in 0.5s ease-out forwards; }
.animate-rise { animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
.animate-fade-in { animation: fade-in 0.35s ease-out both; }
.animate-rise { animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-bounce-slow { animation: bounce-slow 3s infinite; }
.animate-shake { animation: shake 0.4s ease-in-out; }
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes rise {
from { opacity: 0; transform: translateY(20px); }
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes bounce-slow {
+29 -9
View File
@@ -1,15 +1,24 @@
import { useState } from 'react';
import { lazy, Suspense, useState } from 'react';
import { Sidebar } from '../components/Sidebar';
import { MobileTopBar } from '../components/MobileTopBar';
import { DashboardPage } from '../pages/DashboardPage';
import { FocusPage } from '../pages/FocusPage';
import { useWebsocket } from '../hooks/useWebsocket';
// Focus mode carries the gesture/animation code: load it only when used.
const FocusPage = lazy(() => import('../pages/FocusPage').then((m) => ({ default: m.FocusPage })));
export function RootLayout() {
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
const [isFocusMode, setIsFocusMode] = useState(false);
// One socket for the whole session (not per page mount).
useWebsocket();
return (
<div className="flex h-screen bg-carbon overflow-hidden">
<div className="flex h-dvh bg-carbon overflow-hidden">
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[80] btn-primary">
Aller au contenu
</a>
<Sidebar
onSelectFeed={setSelectedFeedId}
selectedFeedId={selectedFeedId}
@@ -17,17 +26,28 @@ export function RootLayout() {
isFocusMode={isFocusMode}
/>
{isFocusMode ? (
<FocusPage onExit={() => setIsFocusMode(false)} />
) : (
<div className="flex-1 flex flex-col min-w-0">
<div id="main-content" className="flex-1 flex flex-col min-w-0">
{!isFocusMode && (
<MobileTopBar
onSelectFeed={setSelectedFeedId}
selectedFeedId={selectedFeedId}
onEnterFocus={() => setIsFocusMode(true)}
/>
<DashboardPage selectedFeedId={selectedFeedId} onEnterFocus={() => setIsFocusMode(true)} />
</div>
)}
{/* Kept mounted in focus mode so the list keeps its scroll position. */}
<DashboardPage selectedFeedId={selectedFeedId} onEnterFocus={() => setIsFocusMode(true)} hidden={isFocusMode} />
</div>
{isFocusMode && (
<Suspense
fallback={
<div className="fixed inset-0 z-50 bg-carbon flex items-center justify-center" role="status" aria-label="Chargement">
<div className="w-12 h-12 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
</div>
}
>
<FocusPage onExit={() => setIsFocusMode(false)} />
</Suspense>
)}
</div>
);
+28
View File
@@ -0,0 +1,28 @@
// Formatters are built once: constructing Intl objects per render is costly in long lists.
const shortDate = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const longDate = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' });
const relative = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' });
export function formatShort(iso?: string) {
return iso ? shortDate.format(new Date(iso)) : '';
}
export function formatLong(iso?: string) {
return iso ? longDate.format(new Date(iso)) : '';
}
/** "il y a 5 min", "hier"… falling back to a short date after a week. */
export function formatRelative(iso?: string) {
if (!iso) return '';
const diff = (new Date(iso).getTime() - Date.now()) / 1000;
const abs = Math.abs(diff);
if (abs < 60) return "à l'instant";
if (abs < 3600) return relative.format(Math.round(diff / 60), 'minute');
if (abs < 86400) return relative.format(Math.round(diff / 3600), 'hour');
if (abs < 7 * 86400) return relative.format(Math.round(diff / 86400), 'day');
return shortDate.format(new Date(iso));
}
export function readingTimeLabel(minutes?: number) {
return `${Math.max(1, minutes ?? 1)} min`;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Prepares sanitized article HTML for display: lazy-loads images, drops the
* first image when it duplicates the hero, and strips tracking pixels.
* The HTML is already sanitized server-side; DOMParser never runs scripts.
*/
export function prepareArticleHtml(html: string, heroUrl?: string): string {
if (!html) return '';
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const imgs = Array.from(doc.body.querySelectorAll('img'));
const heroKey = heroUrl ? stripQuery(heroUrl) : '';
imgs.forEach((img, i) => {
const src = img.getAttribute('src') ?? '';
if (!src || img.getAttribute('width') === '1' || img.getAttribute('height') === '1') {
img.remove();
return;
}
if (i === 0 && heroKey && stripQuery(src) === heroKey) {
(img.closest('figure') ?? img).remove();
return;
}
img.setAttribute('loading', 'lazy');
img.setAttribute('decoding', 'async');
img.setAttribute('referrerpolicy', 'no-referrer');
});
// Empty paragraphs left behind by removed images / feed markup.
doc.body.querySelectorAll('p').forEach((p) => {
if (!p.textContent?.trim() && !p.querySelector('img,video,iframe')) p.remove();
});
return doc.body.innerHTML;
}
function stripQuery(u: string) {
const i = u.search(/[?#]/);
return i === -1 ? u : u.slice(0, i);
}
+16 -1
View File
@@ -1,10 +1,25 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { registerSW } from 'virtual:pwa-register'
// Self-hosted variable fonts: no third-party request, cached offline by the SW.
// Browsers only download the unicode-range subsets a page actually uses.
import '@fontsource-variable/inter/wght.css'
import '@fontsource-variable/lora/wght.css'
import '@fontsource-variable/lora/wght-italic.css'
import '@fontsource-variable/playfair-display/wght.css'
import '@fontsource-variable/playfair-display/wght-italic.css'
import '@fontsource/atkinson-hyperlegible/latin-400.css'
import '@fontsource/atkinson-hyperlegible/latin-700.css'
import './index.css'
import App from './App.tsx'
import { useUpdate } from './stores/updateStore'
registerSW({ immediate: true })
// Prompt-based updates: a new version never reloads the page mid-read.
const updateSW = registerSW({
onNeedRefresh() {
useUpdate.getState().setAvailable(() => updateSW(true))
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
+347 -205
View File
@@ -1,214 +1,331 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Masonry from 'react-masonry-css';
import { articlesApi } from '../api/articles';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { articlesApi, cursorAfter, type Article } from '../api/articles';
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';
import { Reader } from '../components/reader/Reader';
import { SearchBox } from '../components/SearchBox';
import { ShortcutsHelp } from '../components/ShortcutsHelp';
import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions';
import { useLive } from '../hooks/useWebsocket';
import { prefersReducedMotion } from '../hooks/useIsMobile';
import { usePrefs } from '../stores/prefsStore';
const breakpointColumnsObj = { default: 3, 1100: 2, 700: 1 };
const ITEMS_PER_PAGE = 24;
const PAGE_SIZE = 30;
interface DashboardPageProps {
selectedFeedId: string | null;
onEnterFocus: () => void;
/** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */
hidden?: boolean;
}
type ArticlePages = { pages: Article[][] };
function timestamp(a: Article): number {
return new Date(a.published_at || a.created_at).getTime();
function isTypingTarget(t: EventTarget | null) {
const el = t as HTMLElement | null;
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
}
export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) {
const queryClient = useQueryClient();
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const isMobile = useIsMobile();
const loadMoreRef = useRef<HTMLDivElement>(null);
export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: DashboardPageProps) {
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const [query, setQuery] = useState('');
const [showHelp, setShowHelp] = useState(false);
const mainRef = useRef<HTMLElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 400);
return () => clearTimeout(timer);
}, [searchQuery]);
const unreadPref = usePrefs((s) => s.unreadOnly);
const setUnreadOnly = usePrefs((s) => s.setUnreadOnly);
const newCount = useLive((s) => s.newCount);
const clearNew = useLive((s) => s.clearNew);
const { setRead, toggleRead, toggleFavorite } = useArticleActions();
useWebsocket();
const favorites = selectedFeedId === 'favorites';
const feedId = favorites ? null : selectedFeedId;
const searching = query.length > 0;
const unreadOnly = unreadPref && !favorites && !searching;
const queryKey = ['articles', selectedFeedId, debouncedQuery];
const listKey = useMemo(
() => ['articles', { feedId, favorites, unread: unreadOnly, q: query }] as const,
[feedId, favorites, unreadOnly, query],
);
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 });
return articlesApi.list({ ...options, feed_id: selectedFeedId || undefined });
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) =>
lastPage.length < ITEMS_PER_PAGE ? undefined : allPages.length * ITEMS_PER_PAGE,
});
const sortedArticles = useMemo(() => {
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);
const { data, isPending, isError, refetch, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, isPlaceholderData } =
useInfiniteQuery({
queryKey: listKey,
queryFn: ({ pageParam }) =>
searching
? articlesApi.search(query, PAGE_SIZE, Number(pageParam) || 0)
: articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }),
initialPageParam: '' as string,
getNextPageParam: (last, all) => {
if (last.length < PAGE_SIZE) return undefined;
return searching ? String(all.length * PAGE_SIZE) : cursorAfter(last[last.length - 1]);
},
// Keep showing the previous list while a new filter/search loads.
placeholderData: keepPreviousData,
});
}, [data]);
const unreadCount = useMemo(() => sortedArticles.filter((a) => !a.is_read).length, [sortedArticles]);
const articles = useMemo(() => data?.pages.flat() ?? [], [data]);
// Infinite scroll
const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
const unreadTotal = useMemo(() => {
if (!feeds) return 0;
if (feedId) return feeds.find((f) => f.id === feedId)?.unread_count ?? 0;
return feeds.reduce((n, f) => n + (f.unread_count ?? 0), 0);
}, [feeds, feedId]);
const feedTitle = feedId ? feeds?.find((f) => f.id === feedId)?.title : undefined;
// ---------- Reader state lives in the URL (?a=<id>) so Back closes it ----------
const openId = params.get('a');
const openIndex = openId ? articles.findIndex((a) => a.id === openId) : -1;
const deepLinked = useQuery({
queryKey: ['article', openId],
queryFn: () => articlesApi.get(openId!),
enabled: !!openId && openIndex === -1 && !findCachedArticle(qc, openId),
});
const openArticle: Article | undefined =
openIndex >= 0 ? articles[openIndex] : openId ? (findCachedArticle(qc, openId) ?? deepLinked.data) : undefined;
const openReader = useCallback(
(a: Article, replace = false) => {
if (!a.is_read) setRead(a, true);
setParams(
(p) => {
const n = new URLSearchParams(p);
n.set('a', a.id);
return n;
},
{ replace, state: { reader: true } },
);
},
[setParams, setRead],
);
const closeReader = useCallback(() => {
if ((window.history.state?.usr as { reader?: boolean } | undefined)?.reader) {
window.history.back();
} else {
setParams((p) => {
const n = new URLSearchParams(p);
n.delete('a');
return n;
}, { replace: true });
}
}, [setParams]);
const goNext = useCallback(async () => {
if (openIndex < 0) return;
if (openIndex + 1 < articles.length) {
openReader(articles[openIndex + 1], true);
} else if (hasNextPage) {
const res = await fetchNextPage();
const all = res.data?.pages.flat() ?? [];
if (all[openIndex + 1]) openReader(all[openIndex + 1], true);
} else {
closeReader();
}
}, [openIndex, articles, hasNextPage, fetchNextPage, openReader, closeReader]);
const goPrev = useCallback(() => {
if (openIndex > 0) openReader(articles[openIndex - 1], true);
}, [openIndex, articles, openReader]);
// ---------- Infinite scroll (prefetch well before the end) ----------
useEffect(() => {
const target = loadMoreRef.current;
if (!target) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
},
{ threshold: 0.1 }
{ root: mainRef.current, rootMargin: '0px 0px 1200px 0px' },
);
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
observer.observe(target);
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);
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
const refreshMutation = useMutation({
mutationFn: () => feedsApi.refresh(),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const scrollTop = () => mainRef.current?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
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 }) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old: ArticlePages | undefined) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => page.map((a) => (a.id === id ? { ...a, is_read } : a))),
};
});
return { previousData };
const refreshMutation = useMutation({ mutationFn: () => feedsApi.refresh() });
const markAllMutation = useMutation({
mutationFn: () => (feedId ? articlesApi.markAllRead(feedId) : articlesApi.markAllReadGlobal()),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['articles'] });
qc.invalidateQueries({ queryKey: ['feeds'] });
},
onError: (_e, _v, context) => {
if (context?.previousData) queryClient.setQueryData(queryKey, context.previousData);
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const toggleFavoriteMutation = useMutation({
mutationFn: articlesApi.toggleFavorite,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const showNew = () => {
clearNew();
qc.invalidateQueries({ queryKey: ['articles'] });
scrollTop();
};
const heading = debouncedQuery
? `Résultats : « ${debouncedQuery} »`
: selectedFeedId === 'favorites'
// ---------- Keyboard navigation on the list ----------
useEffect(() => {
if (hidden || openId) return;
const cardAt = (i: number) => mainRef.current?.querySelector<HTMLElement>(`[data-index="${i}"] h2 button`);
const currentIndex = () => {
const el = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>('[data-index]');
return el ? Number(el.dataset.index) : -1;
};
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || isTypingTarget(e.target)) return;
const i = currentIndex();
const current = i >= 0 ? articles[i] : undefined;
switch (e.key) {
case 'j':
case 'n': {
const next = Math.min(articles.length - 1, i + 1);
cardAt(next)?.focus();
cardAt(next)?.scrollIntoView({ block: 'nearest' });
if (next >= articles.length - 3 && hasNextPage) fetchNextPage();
break;
}
case 'k':
case 'p': {
const prev = Math.max(0, i - 1);
cardAt(prev)?.focus();
cardAt(prev)?.scrollIntoView({ block: 'nearest' });
break;
}
case 'o':
if (current) openReader(current);
break;
case 'm':
if (current) toggleRead(current);
break;
case 's':
case 'f':
if (current) toggleFavorite(current);
break;
case 'v':
if (current?.url) window.open(current.url, '_blank', 'noopener,noreferrer');
break;
case '/':
searchRef.current?.focus();
break;
case 'u':
setUnreadOnly(!unreadPref);
break;
case 'r':
refreshMutation.mutate();
break;
case '?':
setShowHelp(true);
break;
default:
return;
}
e.preventDefault();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]);
const heading = searching
? `« ${query} »`
: favorites
? 'Mes favoris'
: selectedFeedId
? 'Archives du flux'
: 'La Une';
if (isLoading) {
return (
<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>
);
}
: feedTitle ?? 'La Une';
return (
<main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon relative">
{/* Back to top */}
<button
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" 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-6 md:px-12 py-10 md:py-14">
<main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}>
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12 py-8 md:py-12">
{/* 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">
<header className="mb-8 border-b border-paper-muted/15 pb-6">
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-5">
<div className="min-w-0 flex-1 basis-72">
<p className="eyebrow mb-2">{searching ? 'Recherche' : 'Édition du jour'}</p>
<h1 className="text-4xl md:text-6xl font-serif italic text-paper-white tracking-tight text-balance break-words">
{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 className="text-paper-muted text-sm mt-3 font-reading" aria-live="polite">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
{!searching && !favorites && (
<span className="text-nature font-medium"> · {unreadTotal} non lu{unreadTotal > 1 ? 's' : ''}</span>
)}
</p>
</div>
<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 className="flex flex-wrap items-center gap-2 sm:gap-3 w-full sm:w-auto">
<div className="w-full sm:w-auto order-last sm:order-none">
<SearchBox ref={searchRef} onSearch={setQuery} />
</div>
{!favorites && !searching && (
<div className="segmented" role="group" aria-label="Filtrer les articles">
<button aria-pressed={unreadPref} onClick={() => setUnreadOnly(true)}>Non lus</button>
<button aria-pressed={!unreadPref} onClick={() => setUnreadOnly(false)}>Tous</button>
</div>
)}
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className="btn-secondary"
className="icon-btn"
title="Actualiser les flux (r)"
aria-label="Actualiser les flux"
>
<svg className={`w-3.5 h-3.5 ${refreshMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<svg className={`w-4 h-4 ${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>
{!favorites && unreadTotal > 0 && (
<button
onClick={() => {
if (window.confirm(`Marquer ${unreadTotal} article${unreadTotal > 1 ? 's' : ''} comme lu${unreadTotal > 1 ? 's' : ''} ?`)) {
markAllMutation.mutate();
}
}}
disabled={markAllMutation.isPending}
className="icon-btn"
title="Tout marquer comme lu"
aria-label="Tout marquer comme lu"
>
<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="M2 13l4 4L16 7m-4 10l1 1L22 7" />
</svg>
</button>
)}
<button onClick={() => setShowHelp(true)} className="icon-btn hidden md:inline-flex" title="Raccourcis clavier (?)" aria-label="Raccourcis clavier">
<span className="font-semibold">?</span>
</button>
</div>
</div>
</header>
{/* New articles announcement (never inserted under the reader's eyes) */}
{newCount > 0 && !searching && (
<div className="sticky top-3 z-30 flex justify-center -mt-2 mb-6 pointer-events-none">
<button onClick={showNew} className="btn-primary pointer-events-auto shadow-lg animate-rise">
{newCount} nouvel{newCount > 1 ? 's' : ''} article{newCount > 1 ? 's' : ''} · Afficher
</button>
</div>
)}
{/* Focus CTA */}
{!selectedFeedId && !debouncedQuery && unreadCount > 0 && (
{!feedId && !favorites && !searching && unreadTotal > 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]"
className="group w-full mb-8 relative overflow-hidden rounded-2xl bg-nature text-on-nature flex items-center justify-between gap-4 p-5 sm:p-6 text-left transition-transform hover:scale-[1.003]"
>
<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 className="text-[11px] uppercase tracking-[0.3em] font-bold opacity-85">Mode Focus</span>
<span className="block text-xl sm:text-2xl font-serif italic mt-1">Votre session de lecture</span>
<span className="block opacity-90 text-sm mt-1">{unreadTotal} 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">
<span className="relative z-10 shrink-0 inline-flex items-center gap-2 rounded-full bg-carbon-light text-nature font-bold text-xs uppercase tracking-[0.16em] px-5 min-h-11">
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" />
@@ -218,84 +335,109 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
)}
{/* Grid */}
{sortedArticles.length > 0 ? (
{isPending ? (
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3" aria-label="Chargement des articles" role="status">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="rounded-2xl bg-carbon-light border border-paper-muted/10 overflow-hidden">
<div className="aspect-[16/9] bg-carbon-dark animate-pulse" />
<div className="p-5 space-y-3">
<div className="h-5 w-4/5 rounded bg-carbon-dark animate-pulse" />
<div className="h-4 w-full rounded bg-carbon-dark animate-pulse" />
<div className="h-4 w-2/3 rounded bg-carbon-dark animate-pulse" />
</div>
</div>
))}
</div>
) : isError && articles.length === 0 ? (
<div role="alert" className="flex flex-col items-center justify-center py-24 border border-dashed border-paper-muted/25 rounded-3xl text-center px-6 gap-4">
<p className="text-paper-white font-serif italic text-xl">
{navigator.onLine ? 'Impossible de charger vos articles.' : 'Vous êtes hors ligne.'}
</p>
<button onClick={() => refetch()} className="btn-secondary">Réessayer</button>
</div>
) : articles.length > 0 ? (
<>
<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={(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)}
/>
</div>
<div
className={`grid gap-6 sm:grid-cols-2 xl:grid-cols-3 transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}
>
{articles.map((article, i) => (
<ArticleCard
key={article.id}
index={i}
article={article}
onOpen={openReader}
onToggleRead={toggleRead}
onToggleFavorite={toggleFavorite}
/>
))}
</Masonry>
</div>
<div ref={loadMoreRef} className="mt-16 py-8 flex flex-col items-center gap-3">
<div ref={loadMoreRef} className="mt-14 py-8 flex flex-col items-center gap-3">
{isFetchingNextPage ? (
<>
<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/40 text-[10px] uppercase tracking-[0.3em] font-bold">Défilez pour la suite</p>
) : (
<div className="w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" role="status" aria-label="Chargement" />
) : !hasNextPage ? (
<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" />
<span className="h-px w-16 bg-nature/25" />
<p className="eyebrow text-paper-muted">Fin de l'édition</p>
<span className="h-px w-16 bg-nature/25" />
</div>
)}
) : null}
</div>
</>
) : (
<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.'}
<div className="flex flex-col items-center justify-center py-24 border border-dashed border-paper-muted/25 rounded-3xl text-center px-6 gap-3">
<p className="text-paper-white font-serif italic text-2xl">
{searching
? `Aucun article pour « ${query} ».`
: favorites
? 'Aucun favori pour le moment.'
: unreadOnly && (feeds?.length ?? 0) > 0
? 'Vous êtes à jour 🌿'
: 'Votre bibliothèque est vide.'}
</p>
<p className="eyebrow text-paper-muted/50">
{debouncedQuery ? 'Tentez une recherche plus large.' : 'Ajoutez un flux pour commencer.'}
<p className="text-paper-muted text-sm">
{searching
? 'Essayez d’autres mots-clés.'
: favorites
? 'Appuyez sur l’étoile d’un article pour le garder ici.'
: unreadOnly && (feeds?.length ?? 0) > 0
? 'Tous les articles sont lus.'
: 'Ajoutez un flux avec le bouton +.'}
</p>
{unreadOnly && !searching && (feeds?.length ?? 0) > 0 && (
<button onClick={() => setUnreadOnly(false)} className="btn-secondary mt-2">Voir tous les articles</button>
)}
</div>
)}
</div>
{selectedArticle && (
isMobile ? (
<MobileReaderView
article={selectedArticle}
onClose={() => setSelectedArticle(null)}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
onNext={() => {
const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
if (idx < sortedArticles.length - 1) {
const next = sortedArticles[idx + 1];
setSelectedArticle(next);
if (!next.is_read) toggleReadMutation.mutate({ id: next.id, is_read: true });
} else if (hasNextPage) {
fetchNextPage();
} else {
setSelectedArticle(null);
}
}}
onPrev={() => {
const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
if (idx > 0) setSelectedArticle(sortedArticles[idx - 1]);
}}
/>
) : (
<ReaderView
article={selectedArticle}
onClose={() => setSelectedArticle(null)}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
/>
)
{/* Back to top */}
<button
onClick={scrollTop}
className={`fixed bottom-6 right-6 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'
}`}
aria-label="Retour en haut"
tabIndex={showScrollTop ? 0 : -1}
>
<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>
{openArticle && !hidden && (
<Reader
key={openArticle.id}
article={openArticle}
next={openIndex >= 0 ? articles[openIndex + 1] : undefined}
hasPrev={openIndex > 0}
onClose={closeReader}
onNext={openIndex >= 0 && (openIndex + 1 < articles.length || hasNextPage) ? goNext : undefined}
onPrev={goPrev}
/>
)}
{showHelp && <ShortcutsHelp onClose={() => setShowHelp(false)} />}
</main>
);
}
+49 -56
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { articlesApi, type Article } from '../api/articles';
import { useQuery } from '@tanstack/react-query';
import { MotionConfig } from 'framer-motion';
import { articlesApi } from '../api/articles';
import { FocusCardStack } from '../components/focus/FocusCardStack';
import { FocusEmptyState } from '../components/focus/FocusEmptyState';
@@ -9,74 +10,66 @@ interface FocusPageProps {
}
export function FocusPage({ onExit }: FocusPageProps) {
const queryClient = useQueryClient();
const [isComplete, setIsComplete] = useState(false);
const { data: articles, isLoading } = useQuery({
queryKey: ['articles', 'focus-mode'],
// Snapshot of unread articles for the session. Read/favorite updates are
// patched into this cache entry, never refetched, so the deck stays stable.
const { data: articles, isPending, isError, refetch } = useQuery({
queryKey: ['articles', 'focus'],
queryFn: () => articlesApi.list({ unread: true, limit: 100 }),
staleTime: 0,
staleTime: Infinity,
gcTime: 0,
});
// Snapshot the unread set once into a stable deck for the whole session,
// so marking articles read mid-session never reshuffles the stack. This is
// the React-endorsed "adjust state during render" pattern (guarded to run
// exactly once), not an effect.
const [deck, setDeck] = useState<Article[]>([]);
const [seeded, setSeeded] = useState(false);
if (!seeded && articles && articles.length > 0) {
setDeck(articles);
setSeeded(true);
}
// Deck order is frozen on first load (ids only; live data comes from the cache).
const [deckIds, setDeckIds] = useState<string[] | null>(null);
if (deckIds === null && articles) setDeckIds(articles.map((a) => a.id));
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['feeds'] });
};
const byId = new Map((articles ?? []).map((a) => [a.id, a]));
const deck = (deckIds ?? []).map((id) => byId.get(id)).filter((a) => !!a);
const markReadMutation = useMutation({ mutationFn: (id: string) => articlesApi.markRead(id), onSuccess: invalidate });
const toggleFavoriteMutation = useMutation({ mutationFn: (id: string) => articlesApi.toggleFavorite(id), onSuccess: invalidate });
if (isLoading && deck.length === 0) {
return (
<div className="flex items-center justify-center h-full bg-carbon/60 backdrop-blur-xl">
let body: React.ReactNode;
if (isPending) {
body = (
<div className="flex-1 flex items-center justify-center" role="status" aria-label="Chargement">
<div className="w-14 h-14 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
</div>
);
}
if (deck.length === 0 || isComplete) {
return (
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-2xl flex items-center justify-center">
<FocusEmptyState onBack={onExit} />
} else if (isError) {
body = (
<div role="alert" className="flex-1 flex flex-col items-center justify-center gap-4 text-center">
<p className="font-serif italic text-xl text-paper-white">Impossible de charger vos articles.</p>
<button onClick={() => refetch()} className="btn-secondary">Réessayer</button>
</div>
);
} else if (deck.length === 0 || isComplete) {
body = <FocusEmptyState onBack={onExit} />;
} else {
body = (
<main className="flex-1 flex items-center justify-center p-4 min-h-0">
<FocusCardStack articles={deck} onEmpty={() => setIsComplete(true)} onExit={onExit} />
</main>
);
}
return (
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-2xl flex flex-col">
<header className="absolute top-0 left-0 right-0 p-6 flex justify-between items-center z-50">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-earth animate-pulse" />
<span className="eyebrow text-paper-white">Mode Focus</span>
</div>
<button onClick={onExit} className="btn-secondary" title="Quitter le mode Focus">
<span className="hidden sm:inline">Tableau de bord</span>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<main className="flex-1 flex items-center justify-center p-4">
<FocusCardStack
articles={deck}
onMarkRead={(id) => markReadMutation.mutate(id)}
onKeep={() => { /* skip — no API action */ }}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
onEmpty={() => setIsComplete(true)}
/>
</main>
</div>
<MotionConfig reducedMotion="user">
<div className="fixed inset-0 z-50 bg-carbon flex flex-col animate-fade-in" role="region" aria-label="Mode Focus">
<header className="flex justify-between items-center p-4 sm:p-6 shrink-0">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-earth animate-pulse" aria-hidden="true" />
<span className="eyebrow text-paper-white">Mode Focus</span>
</div>
<button onClick={onExit} className="btn-secondary" title="Quitter le mode Focus (Échap)">
<span className="hidden sm:inline">Tableau de bord</span>
<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="M6 18L18 6M6 6l12 12" />
</svg>
<span className="sr-only sm:hidden">Quitter</span>
</button>
</header>
{body}
</div>
</MotionConfig>
);
}
+14 -1
View File
@@ -10,6 +10,7 @@ interface User {
interface AuthState {
user: User | null;
isAuthenticated: boolean;
/** True until the first /users/me answer when nothing is persisted. */
isLoading: boolean;
setUser: (user: User | null) => void;
setLoading: (loading: boolean) => void;
@@ -24,7 +25,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: true,
setUser: (user) => set({ user, isAuthenticated: !!user, isLoading: false }),
setLoading: (isLoading) => set({ isLoading }),
logout: () => set({ user: null, isAuthenticated: false }),
logout: () => set({ user: null, isAuthenticated: false, isLoading: false }),
}),
{
name: 'auth-storage',
@@ -32,3 +33,15 @@ export const useAuthStore = create<AuthState>()(
}
)
);
/** Clears every client-side copy of the user's data (logout / expired session). */
export async function clearUserData() {
try {
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k.startsWith('api') || k === 'images').map((k) => caches.delete(k)));
}
} catch {
/* ignore */
}
}
+90
View File
@@ -0,0 +1,90 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type ThemePref = 'system' | 'light' | 'sepia' | 'dark';
export type ReaderFont = 'serif' | 'sans' | 'legible';
export type ReaderWidth = 'narrow' | 'medium' | 'wide';
interface PrefsState {
theme: ThemePref;
/** Article body font size in px. */
fontSize: number;
lineHeight: number;
width: ReaderWidth;
font: ReaderFont;
/** Dashboard filter: show only unread articles. */
unreadOnly: boolean;
setTheme: (t: ThemePref) => void;
setFontSize: (n: number) => void;
setLineHeight: (n: number) => void;
setWidth: (w: ReaderWidth) => void;
setFont: (f: ReaderFont) => void;
setUnreadOnly: (v: boolean) => void;
resetReader: () => void;
}
// Research-backed defaults: ~65ch measure, 19px body, 1.65 line-height.
const READER_DEFAULTS = { fontSize: 19, lineHeight: 1.65, width: 'medium' as ReaderWidth, font: 'serif' as ReaderFont };
export const FONT_SIZE_MIN = 15;
export const FONT_SIZE_MAX = 26;
const THEME_COLORS: Record<Exclude<ThemePref, 'system'>, string> = {
light: '#F7F4EC',
sepia: '#F4ECD8',
dark: '#121612',
};
function resolveTheme(pref: ThemePref): Exclude<ThemePref, 'system'> {
if (pref !== 'system') return pref;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/** Applies the theme classes on <html>; mirrored by public/theme-init.js to avoid a flash on load. */
export function applyTheme(pref: ThemePref) {
const theme = resolveTheme(pref);
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('sepia', theme === 'sepia');
root.style.colorScheme = theme === 'dark' ? 'dark' : 'light';
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLORS[theme]);
try {
if (pref === 'system') localStorage.removeItem('theme');
else localStorage.setItem('theme', pref);
} catch {
/* storage unavailable (private mode) */
}
}
export const usePrefs = create<PrefsState>()(
persist(
(set) => ({
theme: 'system',
...READER_DEFAULTS,
unreadOnly: true,
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
},
setFontSize: (fontSize) => set({ fontSize: Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, fontSize)) }),
setLineHeight: (lineHeight) => set({ lineHeight }),
setWidth: (width) => set({ width }),
setFont: (font) => set({ font }),
setUnreadOnly: (unreadOnly) => set({ unreadOnly }),
resetReader: () => set(READER_DEFAULTS),
}),
{
name: 'flowreader-prefs',
onRehydrateStorage: () => (state) => {
if (state) applyTheme(state.theme);
},
},
),
);
// Follow OS changes while in "system" mode.
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (usePrefs.getState().theme === 'system') applyTheme('system');
});
}
+14
View File
@@ -0,0 +1,14 @@
import { create } from 'zustand';
interface UpdateState {
/** Set when a new service worker is waiting; calling it reloads into the new version. */
apply: (() => void) | null;
setAvailable: (apply: () => void) => void;
dismiss: () => void;
}
export const useUpdate = create<UpdateState>((set) => ({
apply: null,
setAvailable: (apply) => set({ apply }),
dismiss: () => set({ apply: null }),
}));
-45
View File
@@ -1,45 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
carbon: {
DEFAULT: '#F9F7F1', // Warm Cream (Newspaper)
light: '#FFFFFF', // Pure White (Cards)
dark: '#E7E5E4', // Stone-200 (Borders/Inputs)
},
nature: {
DEFAULT: '#166534', // Green-800 (Forest Green - Primary Action)
light: '#15803d', // Green-700 (Hover State)
glow: 'rgba(22, 101, 52, 0.2)',
},
earth: {
DEFAULT: '#A0522D', // Sienna (Secondary/Accent)
muted: '#D6CBC7', // Warm Gray
},
paper: {
white: '#1C1917', // Stone-900 (Primary Text - Soft Black)
muted: '#57534E', // Stone-600 (Secondary Text)
}
},
fontFamily: {
serif: ['"Playfair Display"', 'serif'],
sans: ['Inter', 'sans-serif'],
reading: ['Lora', 'serif'],
},
lineHeight: {
'magazine': '1.7',
},
maxWidth: {
'reading': '700px',
}
},
},
plugins: [
require('tailwindcss-animate'),
],
}
+59 -25
View File
@@ -9,62 +9,96 @@ export default defineConfig({
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'robots.txt', 'apple-touch-icon.png'],
// Never reload the page under the reader: the app shows an update toast.
registerType: 'prompt',
includeAssets: ['favicon.svg', 'theme-init.js'],
manifest: {
name: 'FlowReader',
short_name: 'FlowReader',
description: 'Lecteur RSS minimaliste et rapide',
theme_color: '#166534',
background_color: '#166534',
lang: 'fr',
theme_color: '#15643A',
background_color: '#F7F4EC',
display: 'standalone',
start_url: '/',
icons: [
{
src: 'favicon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable'
}
]
purpose: 'any',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
// App shell + Latin font subsets only (other subsets load on demand).
globPatterns: ['**/*.{js,css,html,svg}', '**/*-latin-wght-*.woff2', '**/*-latin-[47]00-*.woff2'],
navigateFallbackDenylist: [/^\/api\//, /^\/health/],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/images\.unsplash\.com\/.*/i,
handler: 'CacheFirst',
// Opened articles stay readable offline.
urlPattern: ({ url, request }) =>
request.method === 'GET' && /^\/api\/v1\/articles\/[0-9a-f-]{36}$/.test(url.pathname),
handler: 'NetworkFirst',
options: {
cacheName: 'unsplash-images',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 Days
},
cacheableResponse: {
statuses: [0, 200],
},
cacheName: 'api-articles',
networkTimeoutSeconds: 4,
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 },
cacheableResponse: { statuses: [200] },
},
},
{
urlPattern: /\/api\/v1\/.*/i,
urlPattern: ({ url, request }) =>
request.method === 'GET' &&
/^\/api\/v1\/(articles|feeds)(\/|$|\?)/.test(url.pathname) &&
!url.pathname.startsWith('/api/v1/feeds/export'),
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 Hours
},
cacheName: 'api-lists',
networkTimeoutSeconds: 4,
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 },
cacheableResponse: { statuses: [200] },
},
},
{
urlPattern: ({ request }) => request.destination === 'image',
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'images',
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 60 * 24 * 30, purgeOnQuotaError: true },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ request }) => request.destination === 'font',
handler: 'CacheFirst',
options: {
cacheName: 'fonts',
expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 },
},
},
],
},
})
],
build: {
rollupOptions: {
output: {
// Long-lived vendor chunk: app deploys don't invalidate React/Query.
manualChunks: {
vendor: ['react', 'react-dom', 'react-dom/client', 'scheduler', 'react-router-dom', '@tanstack/react-query', 'zustand'],
},
},
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
target: process.env.VITE_API_TARGET ?? 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
},