diff --git a/web/index.html b/web/index.html index 5e00a49..7442ccf 100644 --- a/web/index.html +++ b/web/index.html @@ -1,15 +1,16 @@ - + - - - + + + FlowReader | La Une + @@ -17,4 +18,4 @@ - \ No newline at end of file + diff --git a/web/package-lock.json b/web/package-lock.json index 58471cc..84219f8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 151bf65..5f9fbe5 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/public/theme-init.js b/web/public/theme-init.js new file mode 100644 index 0000000..696dc5a --- /dev/null +++ b/web/public/theme-init.js @@ -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) {} +})(); diff --git a/web/src/App.tsx b/web/src/App.tsx index 28cc786..36c3a6f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( +
+
+
+ ); +} + function App() { return ( - - } /> - } /> - }> - } /> - - } /> - + }> + + } /> + } /> + }> + } /> + + } /> + + + diff --git a/web/src/api/articles.ts b/web/src/api/articles.ts index 375f548..3afb2ea 100644 --- a/web/src/api/articles.ts +++ b/web/src/api/articles.ts @@ -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 { + list(options: ListArticlesOptions = {}): Promise { 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(response); + const qs = params.toString(); + return apiFetch(qs ? `${path}?${qs}` : path); }, - async get(id: string): Promise
{ - const response = await fetch(`${API_BASE}/articles/${id}`, { - credentials: 'include', - }); - return handleResponse
(response); + get(id: string): Promise { + return apiFetch(`/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 { - 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(response); + search(query: string, limit = 30, offset = 0): Promise { + const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) }); + return apiFetch(`/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' }); }, }; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index dd2da23..7dd6193 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(response: Response): Promise { 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(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE}${path}`, { credentials: 'include', ...init }); + return handleResponse(response); +} diff --git a/web/src/api/feeds.ts b/web/src/api/feeds.ts index 13e47fc..a7aaa0a 100644 --- a/web/src/api/feeds.ts +++ b/web/src/api/feeds.ts @@ -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 { - const response = await fetch(`${API_BASE}/feeds`, { - credentials: 'include', - }); - return handleResponse(response); + list(): Promise { + return apiFetch('/feeds'); }, async add(data: AddFeedRequest): Promise { @@ -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[] }> { diff --git a/web/src/components/AddFeedModal.tsx b/web/src/components/AddFeedModal.tsx index 8da34c1..f8e9cfd 100644 --- a/web/src/components/AddFeedModal.tsx +++ b/web/src/components/AddFeedModal.tsx @@ -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(null); const fileInputRef = useRef(null); + const previousFocus = useRef(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) => { - 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 ( - - {isOpen && ( - +
e.stopPropagation()}> +
+

Nouveau flux

+ +
+ +

+ Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et les articles automatiquement. +

+ +
{ + e.preventDefault(); + addFeedMutation.mutate(url); + }} + className="space-y-5" > - e.stopPropagation()} +
+ + 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} + /> +
+ + {addFeedMutation.isError && ( + + )} + +
+ + +
+ + + {/* OPML import */} +
+ { + const file = e.target.files?.[0]; + if (file) importMutation.mutate(file); + e.target.value = ''; + }} + /> + -
- -

- Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et le contenu automatiquement. -

- -
-
- - setUrl(e.target.value)} - className="input-field" - placeholder="https://exemple.com/feed" - required - autoFocus - /> -
- - {addFeedMutation.isError && ( -

Échec de l'ajout. Vérifiez l'URL du flux.

- )} - -
- - -
-
- - {/* OPML import */} -
- - -
-
- - )} - + + {importMutation.isPending ? 'Import en cours…' : 'Importer un fichier OPML'} + +

+ {importResult && {importResult}} + {importMutation.isError && Fichier OPML invalide ou trop volumineux.} +

+
+
+ ); } diff --git a/web/src/components/ArticleCard.tsx b/web/src/components/ArticleCard.tsx index 1838018..6d285ee 100644 --- a/web/src/components/ArticleCard.tsx +++ b/web/src/components/ArticleCard.tsx @@ -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 ( +
+ {feed || title} +
+ ); } -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 ( -
- {/* Swipe action hints behind the card */} -
- 10 ? 'opacity-100' : 'opacity-0'}`} - > - {article.is_read ? 'Marquer non lu' : 'Marquer lu'} +
+ {/* Swipe hints behind the card */} +
!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 */} -
-
-
- - +
+ {showImage ? ( + setImgFailed(true)} + /> + ) : ( + + )} + {article.feed_title || 'Journal'} - {!article.is_read && ( -
- + @@ -115,78 +99,66 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
{/* Body */} -
+

- {article.title} + {/* Stretched button: the whole card is clickable and keyboard-focusable. */} +

{article.ai_summary ? ( - ✨ Résumé disponible - ) : article.summary ? ( -

- {plainExcerpt(article.summary)} +

+ ✨ + {article.ai_summary}

+ ) : article.excerpt ? ( +

{article.excerpt}

) : null} -
-
+
+

+ + + {readingTimeLabel(article.reading_time)} +

+
- - -
- -
); } + +/** Memoised: a card re-renders only when its own article object changes. */ +export const ArticleCard = memo(ArticleCardImpl); diff --git a/web/src/components/MobileTopBar.tsx b/web/src/components/MobileTopBar.tsx index 92ae66b..f62434f 100644 --- a/web/src/components/MobileTopBar.tsx +++ b/web/src/components/MobileTopBar.tsx @@ -17,7 +17,7 @@ export function MobileTopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: Mob return ( <> -
+
{/* Feed selector chips */} -
+
+
- setIsAddOpen(false)} /> + {isAddOpen && setIsAddOpen(false)} />} ); } diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index f059719..7aab99e 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -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 ; + if (isLoading) { return ( -
+

Chargement…

); } - if (!isAuthenticated) { - return ; - } - - return ; + return ; } diff --git a/web/src/components/SearchBox.tsx b/web/src/components/SearchBox.tsx new file mode 100644 index 0000000..cde3b03 --- /dev/null +++ b/web/src/components/SearchBox.tsx @@ -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(function SearchBox({ onSearch }, ref) { + const [value, setValue] = useState(''); + + useEffect(() => { + const t = window.setTimeout(() => onSearch(value.trim()), 350); + return () => window.clearTimeout(t); + }, [value, onSearch]); + + return ( +
+ + + + 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" + /> +
+ ); +}); diff --git a/web/src/components/ShareButton.tsx b/web/src/components/ShareButton.tsx index 655ad97..7f71268 100644 --- a/web/src/components/ShareButton.tsx +++ b/web/src/components/ShareButton.tsx @@ -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; 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(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('[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 (
+ {copied && ( + Lien copié + )} - {/* Desktop Fallback Menu */} {isOpen && ( <> -
setIsOpen(false)}>
-
-
+
setIsOpen(false)} /> +
+ {items.map((it) => ( - - - -
- -
+ ))} +
+
)} diff --git a/web/src/components/ShortcutsHelp.tsx b/web/src/components/ShortcutsHelp.tsx new file mode 100644 index 0000000..80614d7 --- /dev/null +++ b/web/src/components/ShortcutsHelp.tsx @@ -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(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 ( +
+
e.stopPropagation()} + > +

Raccourcis clavier

+
+ {GROUPS.map((g) => ( +
+

{g.title}

+
+ {g.keys.map(([k, label]) => ( +
+
{label}
+
{k}
+
+ ))} +
+
+ ))} +
+ +
+
+ ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 3b5004a..9262d26 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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 ( <> -