mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(web): unified reader with reading settings, faster lists, a11y
- New Reader (desktop + mobile): loads full content on open, prefetches the next article, reading progress and time, "Aa" settings (light / sepia / dark / auto theme, serif / sans / Atkinson Hyperlegible font, size, line height, measure), Space-to-next, keyboard shortcuts, focus management, inert background, URL-driven (?a=) so Back closes it. - Dashboard: cursor pagination, unread/all filter, CSS grid (no masonry), no re-sorting under the reader, optimistic cache patches instead of refetch storms, "new articles" banner, error/offline states, j/k/o/m/s/v shortcuts and help dialog. - Cards: keyboard-openable, lazy <img>, memoized, touch-only swipe. - Focus mode: framer drag (drop @use-gesture), Read button, undo restores unread state, arrow keys. - WebSocket: single connection, backoff, cache patching, no console logs. - Contrast fixes (on-nature tokens), 44px targets, lang="fr", reduced motion, self-hosted fonts, theme applied before first paint. - Code splitting + vendor chunk, prompt-based PWA updates, smaller precache, offline cache for opened articles and images, caches cleared on logout; upgrade react-router (CVE), drop unused deps and assets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03e57e4308
commit
360f3cf012
38 files changed
+2557
-1242
No files matched your search
+6
-5
@@ -1,15 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
<meta name="theme-color" content="#F7F4EC" />
|
||||||
<meta name="theme-color" content="#166534" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="FlowReader - Votre édition quotidienne pour une lecture plaisir et sans distractions." />
|
content="FlowReader - Votre édition quotidienne pour une lecture plaisir et sans distractions." />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>FlowReader | La Une</title>
|
<title>FlowReader | La Une</title>
|
||||||
|
<script src="/theme-init.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -17,4 +18,4 @@
|
|||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Generated
+54
-52
@@ -8,15 +8,16 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@use-gesture/react": "^10.3.1",
|
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-masonry-css": "^1.0.16",
|
"react-router-dom": "^7.18.4",
|
||||||
"react-router-dom": "^7.13.0",
|
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2247,6 +2248,42 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -2422,18 +2459,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/plugin-terser": {
|
"node_modules/@rollup/plugin-terser": {
|
||||||
"version": "0.4.4",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz",
|
||||||
"integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
|
"integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"serialize-javascript": "^6.0.1",
|
"serialize-javascript": "^7.0.3",
|
||||||
"smob": "^1.0.0",
|
"smob": "^1.0.0",
|
||||||
"terser": "^5.17.4"
|
"terser": "^5.17.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"rollup": "^2.0.0||^3.0.0||^4.0.0"
|
"rollup": "^2.0.0||^3.0.0||^4.0.0"
|
||||||
@@ -3533,24 +3570,6 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.3",
|
"version": "5.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
|
||||||
@@ -6679,15 +6698,6 @@
|
|||||||
"react": "^19.2.4"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -6699,9 +6709,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.13.0",
|
"version": "7.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -6721,12 +6731,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.13.0",
|
"version": "7.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
||||||
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.13.0"
|
"react-router": "7.18.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -7434,17 +7444,9 @@
|
|||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
|||||||
+5
-4
@@ -10,15 +10,16 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@use-gesture/react": "^10.3.1",
|
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-masonry-css": "^1.0.16",
|
"react-router-dom": "^7.18.4",
|
||||||
"react-router-dom": "^7.13.0",
|
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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
@@ -1,47 +1,83 @@
|
|||||||
import { useEffect } from 'react';
|
import { lazy, Suspense, useEffect } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore, clearUserData } from './stores/authStore';
|
||||||
import { authApi } from './api/auth';
|
import { authApi } from './api/auth';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { ApiError, setUnauthorizedHandler } from './api/client';
|
||||||
import { RegisterPage } from './pages/RegisterPage';
|
|
||||||
import { RootLayout } from './layouts/RootLayout';
|
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
retry: 1,
|
retry: (count, err) => !(err instanceof ApiError && err.status < 500) && count < 2,
|
||||||
staleTime: 5 * 60 * 1000,
|
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 }) {
|
function AuthChecker({ children }: { children: React.ReactNode }) {
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuthStore((state) => state.setUser);
|
||||||
|
const setLoading = useAuthStore((state) => state.setLoading);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authApi.getMe()
|
authApi
|
||||||
|
.getMe()
|
||||||
.then((user) => setUser(user))
|
.then((user) => setUser(user))
|
||||||
.catch(() => setUser(null));
|
.catch((err) => {
|
||||||
}, [setUser]);
|
// 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}</>;
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthChecker>
|
<AuthChecker>
|
||||||
<Routes>
|
<Suspense fallback={<PageFallback />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Routes>
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/" element={<RootLayout />} />
|
<Route element={<ProtectedRoute />}>
|
||||||
</Route>
|
<Route path="/" element={<RootLayout />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
</Route>
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
<UpdateToast />
|
||||||
</AuthChecker>
|
</AuthChecker>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
+47
-85
@@ -1,130 +1,92 @@
|
|||||||
import { API_BASE, handleResponse } from './client';
|
import { apiFetch } from './client';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export { ApiError } from './client';
|
||||||
public status: number;
|
|
||||||
constructor(status: number, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
this.name = 'ApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** List item: light payload (no HTML content). */
|
||||||
export interface Article {
|
export interface Article {
|
||||||
id: string;
|
id: string;
|
||||||
feed_id: string;
|
feed_id: string;
|
||||||
guid: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
content?: string;
|
excerpt?: string;
|
||||||
summary?: string;
|
|
||||||
ai_summary?: string;
|
ai_summary?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
published_at?: string;
|
published_at?: string;
|
||||||
|
sort_at: string;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
is_favorite: boolean;
|
is_favorite: boolean;
|
||||||
read_at?: string;
|
read_at?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
word_count: number;
|
||||||
|
reading_time: number;
|
||||||
feed_title?: string;
|
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 {
|
export interface ListArticlesOptions {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
cursor?: string;
|
||||||
unread?: boolean;
|
unread?: boolean;
|
||||||
favorite?: boolean;
|
favorites?: boolean;
|
||||||
feed_id?: string;
|
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 = {
|
export const articlesApi = {
|
||||||
async list(options: ListArticlesOptions = {}): Promise<Article[]> {
|
list(options: ListArticlesOptions = {}): Promise<Article[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options.limit) params.append('limit', options.limit.toString());
|
if (options.limit) params.set('limit', String(options.limit));
|
||||||
if (options.offset) params.append('offset', options.offset.toString());
|
if (options.cursor) params.set('cursor', options.cursor);
|
||||||
if (options.unread) params.append('unread', 'true');
|
if (options.unread) params.set('unread', 'true');
|
||||||
if (options.favorite) params.append('favorite', 'true');
|
|
||||||
|
|
||||||
let url = `${API_BASE}/articles`;
|
let path = '/articles';
|
||||||
if (options.favorite) {
|
if (options.favorites) path = '/articles/favorites';
|
||||||
url = `${API_BASE}/articles/favorites`;
|
else if (options.feedId) path = `/feeds/${options.feedId}/articles`;
|
||||||
} else if (options.feed_id) {
|
|
||||||
url = `${API_BASE}/feeds/${options.feed_id}/articles`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.toString()) {
|
const qs = params.toString();
|
||||||
url += `?${params.toString()}`;
|
return apiFetch<Article[]>(qs ? `${path}?${qs}` : path);
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse<Article[]>(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(id: string): Promise<Article> {
|
get(id: string): Promise<ArticleDetail> {
|
||||||
const response = await fetch(`${API_BASE}/articles/${id}`, {
|
return apiFetch<ArticleDetail>(`/articles/${id}`);
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse<Article>(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async markRead(id: string): Promise<{ is_read: boolean }> {
|
markRead(id: string): Promise<{ is_read: boolean }> {
|
||||||
const response = await fetch(`${API_BASE}/articles/${id}/read`, {
|
return apiFetch(`/articles/${id}/read`, { method: 'POST' });
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async markUnread(id: string): Promise<{ is_read: boolean }> {
|
markUnread(id: string): Promise<{ is_read: boolean }> {
|
||||||
const response = await fetch(`${API_BASE}/articles/${id}/read`, {
|
return apiFetch(`/articles/${id}/read`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleFavorite(id: string): Promise<{ is_favorite: boolean }> {
|
toggleFavorite(id: string): Promise<{ is_favorite: boolean }> {
|
||||||
const response = await fetch(`${API_BASE}/articles/${id}/favorite`, {
|
return apiFetch(`/articles/${id}/favorite`, { method: 'POST' });
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async markAllRead(feedId: string): Promise<{ message: string }> {
|
markAllRead(feedId: string): Promise<{ message: string; count: number }> {
|
||||||
const response = await fetch(`${API_BASE}/feeds/${feedId}/read-all`, {
|
return apiFetch(`/feeds/${feedId}/read-all`, { method: 'POST' });
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async markAllReadGlobal(): Promise<{ message: string }> {
|
markAllReadGlobal(): Promise<{ message: string; count: number }> {
|
||||||
const response = await fetch(`${API_BASE}/articles/read-all`, {
|
return apiFetch('/articles/read-all', { method: 'POST' });
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async search(query: string, limit: number = 50, offset: number = 0): Promise<Article[]> {
|
search(query: string, limit = 30, offset = 0): Promise<Article[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams({ q: query, limit: String(limit), offset: String(offset) });
|
||||||
params.append('q', query);
|
return apiFetch<Article[]>(`/articles/search?${params}`);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async summarize(id: string): Promise<{ summary: string }> {
|
summarize(id: string): Promise<{ summary: string }> {
|
||||||
const response = await fetch(`${API_BASE}/articles/${id}/summarize`, {
|
return apiFetch(`/articles/${id}/summarize`, { method: 'POST' });
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -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> {
|
export async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
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');
|
throw new ApiError(response.status, error.error || 'Request failed');
|
||||||
}
|
}
|
||||||
return response.json();
|
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
@@ -1,4 +1,4 @@
|
|||||||
import { API_BASE, handleResponse } from './client';
|
import { API_BASE, handleResponse, apiFetch } from './client';
|
||||||
|
|
||||||
export interface Feed {
|
export interface Feed {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,11 +20,8 @@ export interface AddFeedRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const feedsApi = {
|
export const feedsApi = {
|
||||||
async list(): Promise<Feed[]> {
|
list(): Promise<Feed[]> {
|
||||||
const response = await fetch(`${API_BASE}/feeds`, {
|
return apiFetch<Feed[]>('/feeds');
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse<Feed[]>(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async add(data: AddFeedRequest): Promise<Feed> {
|
async add(data: AddFeedRequest): Promise<Feed> {
|
||||||
@@ -62,12 +59,8 @@ export const feedsApi = {
|
|||||||
await handleResponse(response);
|
await handleResponse(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
async refresh(): Promise<{ message: string }> {
|
refresh(): Promise<{ message: string }> {
|
||||||
const response = await fetch(`${API_BASE}/feeds/refresh`, {
|
return apiFetch('/feeds/refresh', { method: 'POST' });
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async importOPML(file: File): Promise<{ imported: number; skipped: number; errors?: string[] }> {
|
async importOPML(file: File): Promise<{ imported: number; skipped: number; errors?: string[] }> {
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { feedsApi } from '../api/feeds';
|
import { feedsApi } from '../api/feeds';
|
||||||
|
import { ApiError } from '../api/client';
|
||||||
|
|
||||||
interface AddFeedModalProps {
|
interface AddFeedModalProps {
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
|
export function AddFeedModal({ onClose }: AddFeedModalProps) {
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
|
const [importResult, setImportResult] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const previousFocus = useRef<HTMLElement | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const addFeedMutation = useMutation({
|
const addFeedMutation = useMutation({
|
||||||
mutationFn: (u: string) => feedsApi.add({ url: u }),
|
mutationFn: (u: string) => feedsApi.add({ url: u.trim() }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||||
setUrl('');
|
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -26,108 +26,116 @@ export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
|
|||||||
mutationFn: (file: File) => feedsApi.importOPML(file),
|
mutationFn: (file: File) => feedsApi.importOPML(file),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
setImportResult(
|
||||||
window.alert(`${res.imported} flux importé(s), ${res.skipped} ignoré(s).`);
|
`${res.imported} flux importé${res.imported > 1 ? 's' : ''}, ${res.skipped} ignoré${res.skipped > 1 ? 's' : ''}.`,
|
||||||
onClose();
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
previousFocus.current = document.activeElement as HTMLElement | null;
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => {
|
||||||
}, [isOpen, onClose]);
|
window.removeEventListener('keydown', onKey);
|
||||||
|
previousFocus.current?.focus?.();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const addError =
|
||||||
e.preventDefault();
|
addFeedMutation.error instanceof ApiError && addFeedMutation.error.status === 409
|
||||||
addFeedMutation.mutate(url);
|
? 'Vous êtes déjà abonné à ce flux.'
|
||||||
};
|
: 'Échec de l’ajout. Vérifiez l’adresse du flux.';
|
||||||
|
|
||||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) importMutation.mutate(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<div
|
||||||
{isOpen && (
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 animate-fade-in"
|
||||||
<motion.div
|
onClick={onClose}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 backdrop-blur-sm"
|
role="dialog"
|
||||||
initial={{ opacity: 0 }}
|
aria-modal="true"
|
||||||
animate={{ opacity: 1 }}
|
aria-labelledby="add-feed-title"
|
||||||
exit={{ opacity: 0 }}
|
>
|
||||||
onClick={onClose}
|
<div className="w-full max-w-md surface-card p-6 sm:p-8 animate-rise" onClick={(e) => e.stopPropagation()}>
|
||||||
role="dialog"
|
<div className="flex justify-between items-center mb-2">
|
||||||
aria-modal="true"
|
<h2 id="add-feed-title" className="text-2xl font-serif italic text-nature">Nouveau flux</h2>
|
||||||
aria-labelledby="add-feed-title"
|
<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
|
<div className="space-y-2">
|
||||||
className="w-full max-w-md surface-card p-8"
|
<label htmlFor="feed-url" className="block eyebrow text-paper-muted">URL du flux</label>
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
<input
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
id="feed-url"
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 12 }}
|
type="url"
|
||||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
inputMode="url"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<h2 id="add-feed-title" className="text-2xl font-serif italic text-nature">Nouveau flux</h2>
|
<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" />
|
||||||
<button onClick={onClose} className="icon-btn" aria-label="Fermer">
|
</svg>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
{importMutation.isPending ? 'Import en cours…' : 'Importer un fichier OPML'}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
</button>
|
||||||
</svg>
|
<p className="text-sm text-center mt-2" role="status" aria-live="polite">
|
||||||
</button>
|
{importResult && <span className="text-nature">{importResult}</span>}
|
||||||
</div>
|
{importMutation.isError && <span className="text-danger">Fichier OPML invalide ou trop volumineux.</span>}
|
||||||
|
</p>
|
||||||
<p className="text-paper-muted text-sm mb-6 leading-relaxed font-reading">
|
</div>
|
||||||
Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et le contenu automatiquement.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+102
-130
@@ -1,113 +1,97 @@
|
|||||||
import { useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import { useSwipeable } from 'react-swipeable';
|
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 {
|
interface ArticleCardProps {
|
||||||
article: Article;
|
article: Article;
|
||||||
onClick: (article: Article) => void;
|
/** Position in the list, used by keyboard navigation (j/k). */
|
||||||
onToggleRead: (id: string, is_read: boolean) => void;
|
index: number;
|
||||||
onToggleFavorite: (id: string) => void;
|
isCurrent?: boolean;
|
||||||
|
onOpen: (article: Article) => void;
|
||||||
|
onToggleRead: (article: Article) => void;
|
||||||
|
onToggleFavorite: (article: Article) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_IMAGE =
|
/** Initial-letter cover used when a feed provides no image (no third-party fallback). */
|
||||||
'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800';
|
function CoverFallback({ title, feed }: { title: string; feed?: string }) {
|
||||||
|
return (
|
||||||
function plainExcerpt(html: string, max = 130): string {
|
<div className="w-full h-full bg-gradient-to-br from-nature/90 to-nature-dark flex items-end p-5">
|
||||||
const text = html.replace(/<[^>]*>?/gm, '').trim();
|
<span className="font-serif italic text-on-nature/90 text-lg leading-snug line-clamp-3">{feed || title}</span>
|
||||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [isSwiping, setIsSwiping] = useState(false);
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
|
||||||
|
|
||||||
const handlers = useSwipeable({
|
// Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text
|
||||||
onSwiping: (event) => {
|
// selection) never trigger it.
|
||||||
if (Math.abs(event.deltaX) < 100) {
|
const swipe = useSwipeable({
|
||||||
setSwipeOffset(event.deltaX);
|
onSwiping: (e) => {
|
||||||
setIsSwiping(true);
|
if (e.dir === 'Left' || e.dir === 'Right') setOffset(Math.max(-110, Math.min(110, e.deltaX)));
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSwipedRight: () => {
|
onSwiped: (e) => {
|
||||||
if (swipeOffset > 50) onToggleRead(article.id, !article.is_read);
|
if (e.deltaX > 90) onToggleRead(article);
|
||||||
resetSwipe();
|
else if (e.deltaX < -90) onToggleFavorite(article);
|
||||||
|
setOffset(0);
|
||||||
},
|
},
|
||||||
onSwipedLeft: () => {
|
trackMouse: false,
|
||||||
if (swipeOffset < -50) onToggleFavorite(article.id);
|
delta: 30,
|
||||||
resetSwipe();
|
|
||||||
},
|
|
||||||
onSwiped: () => resetSwipe(),
|
|
||||||
trackMouse: true,
|
|
||||||
preventScrollOnSwipe: false,
|
preventScrollOnSwipe: false,
|
||||||
delta: 50,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetSwipe = () => {
|
const showImage = article.image_url && !imgFailed;
|
||||||
setSwipeOffset(0);
|
const date = article.published_at || article.created_at;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative cv-auto" data-index={index}>
|
||||||
{/* Swipe action hints behind the card */}
|
{/* Swipe hints behind the card */}
|
||||||
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none" aria-hidden="true">
|
||||||
<span
|
<span className={`chip transition-opacity ${offset > 20 ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
className={`chip transition-opacity duration-300 ${swipeOffset > 10 ? 'opacity-100' : 'opacity-0'}`}
|
{article.is_read ? 'Non lu' : 'Lu'}
|
||||||
>
|
|
||||||
{article.is_read ? 'Marquer non lu' : 'Marquer lu'}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className={`chip bg-earth/15 text-earth transition-opacity ${offset < -20 ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
className={`chip bg-earth/15 text-earth transition-opacity duration-300 ${swipeOffset < -10 ? 'opacity-100' : 'opacity-0'}`}
|
|
||||||
>
|
|
||||||
Favori
|
Favori
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
{...handlers}
|
{...swipe}
|
||||||
onClick={() => !isSwiping && Math.abs(swipeOffset) < 5 && onClick(article)}
|
className={`group relative z-10 flex flex-col h-full overflow-hidden rounded-2xl bg-carbon-light border transition-[border-color,box-shadow,opacity]
|
||||||
className={`group relative z-10 cursor-pointer flex flex-col overflow-hidden rounded-2xl
|
${article.is_read ? 'border-paper-muted/10' : 'border-nature/25'}
|
||||||
bg-carbon-light border transition-colors
|
${isCurrent ? 'ring-2 ring-nature ring-offset-2 ring-offset-carbon' : ''}
|
||||||
${article.is_read ? 'border-paper-muted/10' : 'border-nature/20'}
|
hover:border-nature/45 focus-within:border-nature/45`}
|
||||||
hover:border-nature/40`}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${swipeOffset}px)`,
|
transform: offset ? `translateX(${offset}px)` : undefined,
|
||||||
transition: isSwiping ? 'none' : 'transform 0.45s cubic-bezier(0.22,1,0.36,1)',
|
transition: offset ? 'none' : 'transform 0.35s cubic-bezier(0.22,1,0.36,1)',
|
||||||
boxShadow: 'var(--shadow-soft)',
|
boxShadow: 'var(--shadow-soft)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
<div className="aspect-[16/9] overflow-hidden relative bg-carbon-dark">
|
<div className={`aspect-[16/9] overflow-hidden relative bg-carbon-dark ${article.is_read ? 'opacity-75' : ''}`}>
|
||||||
<div
|
{showImage ? (
|
||||||
className="w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
|
<img
|
||||||
style={{ backgroundImage: `url(${article.image_url || FALLBACK_IMAGE})` }}
|
src={article.image_url}
|
||||||
/>
|
alt=""
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/45 via-transparent to-transparent" />
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
<span className="absolute bottom-3 left-3 chip bg-carbon-light/90 backdrop-blur-sm shadow-sm">
|
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'}
|
{article.feed_title || 'Journal'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{!article.is_read && (
|
{!article.is_read && (
|
||||||
<div
|
<div className="absolute top-0 right-0 w-24 h-24 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||||
className="absolute top-0 right-0 w-24 h-24 overflow-hidden pointer-events-none z-20"
|
<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">
|
||||||
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">
|
|
||||||
Nouveau
|
Nouveau
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,78 +99,66 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* 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
|
<h2
|
||||||
className={`text-2xl font-serif leading-snug transition-colors group-hover:text-nature
|
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-bold'}`}
|
${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>
|
</h2>
|
||||||
|
|
||||||
{article.ai_summary ? (
|
{article.ai_summary ? (
|
||||||
<span className="chip self-start">✨ Résumé disponible</span>
|
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">
|
||||||
) : article.summary ? (
|
<span className="text-nature font-semibold not-italic">✨ </span>
|
||||||
<p className="text-paper-muted text-sm leading-relaxed line-clamp-2 font-reading">
|
{article.ai_summary}
|
||||||
{plainExcerpt(article.summary)}
|
|
||||||
</p>
|
</p>
|
||||||
|
) : article.excerpt ? (
|
||||||
|
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">{article.excerpt}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between pt-3 border-t border-paper-muted/10">
|
<div className="mt-auto flex items-center justify-between pt-2 border-t border-paper-muted/10">
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onToggleRead(article.id, !article.is_read); }}
|
onClick={() => onToggleRead(article)}
|
||||||
className={`p-2 rounded-full border transition-all
|
className="w-11 h-11 inline-flex items-center justify-center rounded-full text-nature hover:bg-nature/10 transition-colors"
|
||||||
${article.is_read
|
title={article.is_read ? 'Marquer comme non lu (m)' : 'Marquer comme lu (m)'}
|
||||||
? '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'}
|
|
||||||
aria-label={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
|
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">
|
<svg className="w-[18px] h-[18px]" 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" />
|
<circle cx="12" cy="12" r="5" strokeWidth={1.8} />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onToggleFavorite(article.id); }}
|
onClick={() => onToggleFavorite(article)}
|
||||||
className={`p-2 rounded-full transition-all ${article.is_favorite ? 'text-earth' : 'text-paper-muted hover:text-earth hover:bg-earth/10'}`}
|
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' : 'Ajouter aux favoris'}
|
title={article.is_favorite ? 'Retirer des favoris (s)' : 'Ajouter aux favoris (s)'}
|
||||||
aria-label={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
|
aria-label="Favori"
|
||||||
aria-pressed={article.is_favorite}
|
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">
|
<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.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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Memoised: a card re-renders only when its own article object changes. */
|
||||||
|
export const ArticleCard = memo(ArticleCardImpl);
|
||||||
@@ -17,7 +17,7 @@ export function MobileTopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: Mob
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<button onClick={() => onSelectFeed(null)} className="text-nature text-xl font-serif italic shrink-0">
|
<button onClick={() => onSelectFeed(null)} className="text-nature text-xl font-serif italic shrink-0">
|
||||||
FlowReader
|
FlowReader
|
||||||
@@ -38,28 +38,30 @@ export function MobileTopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: Mob
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feed selector chips */}
|
{/* 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: null as string | null, label: 'Tous', unread: 0 },
|
||||||
{ id: 'favorites' as string | null, label: 'Favoris' },
|
{ id: 'favorites' as string | null, label: 'Favoris', unread: 0 },
|
||||||
...(feeds ?? []).map((f) => ({ id: f.id as string | null, label: f.title })),
|
...(feeds ?? []).map((f) => ({ id: f.id as string | null, label: f.title, unread: f.unread_count ?? 0 })),
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id ?? 'all'}
|
key={item.id ?? 'all'}
|
||||||
onClick={() => onSelectFeed(item.id)}
|
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
|
selectedFeedId === item.id
|
||||||
? 'bg-nature text-white'
|
? 'bg-nature text-on-nature'
|
||||||
: 'bg-nature/8 text-paper-muted hover:text-nature'
|
: 'bg-nature/10 text-paper-muted hover:text-nature'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{item.unread ? <span className="ml-1.5 opacity-80">{item.unread}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<AddFeedModal isOpen={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
{isAddOpen && <AddFeedModal onClose={() => setIsAddOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,18 +4,18 @@ import { useAuthStore } from '../stores/authStore';
|
|||||||
export function ProtectedRoute() {
|
export function ProtectedRoute() {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
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" />
|
<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>
|
<p className="eyebrow text-paper-muted">Chargement…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
return <Navigate to="/login" replace />;
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,120 +1,110 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { Article } from '../api/articles';
|
import type { Article } from '../api/articles';
|
||||||
|
|
||||||
interface ShareButtonProps {
|
interface ShareButtonProps {
|
||||||
article: Article;
|
article: Pick<Article, 'title' | 'url' | 'excerpt'>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareButton({ article, className = "" }: ShareButtonProps) {
|
export function ShareButton({ article, className = 'icon-btn' }: ShareButtonProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const url = article.url || window.location.href;
|
||||||
|
|
||||||
const shareData = {
|
// Escape closes only the menu, not the reader behind it.
|
||||||
title: article.title || 'Article FlowReader',
|
useEffect(() => {
|
||||||
text: article.summary ? article.summary.substring(0, 100) + '...' : (article.title || ''),
|
if (!isOpen) return;
|
||||||
url: article.url || window.location.href,
|
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 () => {
|
const handleShare = async () => {
|
||||||
// Try Native Share API first (Mobile & Supported Browsers)
|
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share(shareData);
|
await navigator.share({ title: article.title, text: article.excerpt?.slice(0, 120), url });
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('Error sharing:', err);
|
if ((err as DOMException)?.name === 'AbortError') return;
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setIsOpen((o) => !o);
|
||||||
// Fallback to custom menu
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = () => {
|
const copyToClipboard = async () => {
|
||||||
navigator.clipboard.writeText(article.url || window.location.href);
|
try {
|
||||||
setCopied(true);
|
await navigator.clipboard.writeText(url);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} finally {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLink = (href: string) => {
|
||||||
|
window.open(href, '_blank', 'noopener,noreferrer');
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openLink = (url: string) => {
|
const enc = encodeURIComponent;
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
const items = [
|
||||||
setIsOpen(false);
|
{ 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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
className={`p-2 rounded-full transition-all duration-300 hover:bg-nature/10 text-nature ${className}`}
|
className={className}
|
||||||
title="Partager"
|
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">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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 && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)}></div>
|
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||||
<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
|
||||||
<div className="py-1">
|
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
|
<button
|
||||||
onClick={() => openLink(`https://wa.me/?text=${encodeURIComponent((article.title || '') + ' ' + (article.url || ''))}`)}
|
key={it.label}
|
||||||
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"
|
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">
|
{it.label}
|
||||||
<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
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
))}
|
||||||
onClick={() => openLink(`mailto:?subject=${encodeURIComponent(article.title || '')}&body=${encodeURIComponent(article.url || '')}`)}
|
<div className="border-t border-paper-muted/15 my-1" />
|
||||||
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"
|
<button
|
||||||
>
|
role="menuitem"
|
||||||
<span className="text-gray-500">
|
onClick={copyToClipboard}
|
||||||
<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>
|
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>
|
>
|
||||||
Email
|
Copier le lien
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { authApi } from '../api/auth';
|
||||||
import { feedsApi } from '../api/feeds';
|
import { feedsApi } from '../api/feeds';
|
||||||
import { articlesApi } from '../api/articles';
|
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 { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
|
||||||
|
|
||||||
const markAllReadMutation = useMutation({
|
const markAllReadMutation = useMutation({
|
||||||
mutationFn: () => (selectedFeedId ? articlesApi.markAllRead(selectedFeedId) : articlesApi.markAllReadGlobal()),
|
mutationFn: () =>
|
||||||
|
selectedFeedId && selectedFeedId !== 'favorites'
|
||||||
|
? articlesApi.markAllRead(selectedFeedId)
|
||||||
|
: articlesApi.markAllReadGlobal(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
@@ -45,16 +48,19 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
|||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: () => authApi.logout(),
|
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) => {
|
const handleDelete = (id: string) => {
|
||||||
e.stopPropagation();
|
|
||||||
if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) deleteFeedMutation.mutate(id);
|
if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) deleteFeedMutation.mutate(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRename = (e: React.MouseEvent, id: string, currentTitle: string) => {
|
const handleRename = (id: string, currentTitle: string) => {
|
||||||
e.stopPropagation();
|
|
||||||
const newTitle = window.prompt('Renommer le flux :', currentTitle);
|
const newTitle = window.prompt('Renommer le flux :', currentTitle);
|
||||||
if (newTitle && newTitle.trim() !== '' && newTitle !== currentTitle) {
|
if (newTitle && newTitle.trim() !== '' && newTitle !== currentTitle) {
|
||||||
updateFeedMutation.mutate({ id, title: newTitle.trim() });
|
updateFeedMutation.mutate({ id, title: newTitle.trim() });
|
||||||
@@ -73,7 +79,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
|||||||
|
|
||||||
return (
|
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 */}
|
{/* Brand + actions */}
|
||||||
<div className="p-6 pb-3">
|
<div className="p-6 pb-3">
|
||||||
<div className="flex items-center justify-between mb-7">
|
<div className="flex items-center justify-between mb-7">
|
||||||
@@ -111,7 +117,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
|||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
|
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
|
||||||
<div className="group/item">
|
<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="text-sm font-medium flex items-center truncate">
|
||||||
<span className={dot(selectedFeedId === null)} />Tous les articles
|
<span className={dot(selectedFeedId === null)} />Tous les articles
|
||||||
</span>
|
</span>
|
||||||
@@ -119,7 +125,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="group/item">
|
<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="text-sm font-medium flex items-center truncate">
|
||||||
<span className={dot(selectedFeedId === 'favorites')} />Mes favoris
|
<span className={dot(selectedFeedId === 'favorites')} />Mes favoris
|
||||||
</span>
|
</span>
|
||||||
@@ -131,8 +137,8 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
|||||||
onClick={onEnterFocus}
|
onClick={onEnterFocus}
|
||||||
className={`w-full flex items-center px-4 py-2.5 rounded-xl text-left transition-all duration-300 ${
|
className={`w-full flex items-center px-4 py-2.5 rounded-xl text-left transition-all duration-300 ${
|
||||||
isFocusMode
|
isFocusMode
|
||||||
? 'bg-nature text-white shadow-lg shadow-nature/20'
|
? 'bg-nature text-on-nature shadow-lg shadow-nature/20'
|
||||||
: 'bg-nature/5 text-nature border border-nature/20 hover:bg-nature hover:text-white'
|
: '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">
|
<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>
|
</button>
|
||||||
</div>
|
</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) => (
|
{feeds && feeds.length > 0 ? feeds.map((feed) => (
|
||||||
<div key={feed.id} className="group/item">
|
<div key={feed.id} className="group/item relative flex items-center">
|
||||||
<button onClick={() => onSelectFeed(feed.id)} className={navItem(selectedFeedId === feed.id)}>
|
<button
|
||||||
<span className="text-sm font-medium flex items-center truncate">
|
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={dot(selectedFeedId === feed.id)} />
|
||||||
<span className="truncate">{feed.title}</span>
|
<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>
|
||||||
<span className="flex items-center gap-1 shrink-0">
|
{(feed.unread_count ?? 0) > 0 && (
|
||||||
{(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">
|
||||||
<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}
|
||||||
{feed.unread_count}
|
<span className="sr-only"> non lus</span>
|
||||||
</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>
|
|
||||||
</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>
|
</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>
|
</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 +.
|
Aucun flux pour l'instant. Ajoutez-en un avec le bouton +.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +207,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-bold text-paper-white truncate">{user?.email ?? 'Compte'}</p>
|
<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'}
|
{user?.is_admin ? 'Administrateur' : 'Lecteur'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +225,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<AddFeedModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} />
|
{isAddModalOpen && <AddFeedModal onClose={() => setIsAddModalOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { usePrefs } from '../stores/prefsStore';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
function isDarkNow() {
|
||||||
|
return document.documentElement.classList.contains('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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Quick light/dark switch. Sepia and "system" live in the reader settings. */
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
const theme = usePrefs((s) => s.theme);
|
||||||
|
const setTheme = usePrefs((s) => s.setTheme);
|
||||||
useEffect(() => {
|
const isDark = theme === 'dark' || (theme === 'system' && isDarkNow());
|
||||||
const root = window.document.documentElement;
|
|
||||||
root.classList.toggle('dark', theme === 'dark');
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -26,16 +16,10 @@ export function ThemeToggle() {
|
|||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isDark}
|
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'}
|
title={isDark ? 'Activer le mode clair' : 'Activer le mode sombre'}
|
||||||
>
|
>
|
||||||
<motion.span
|
<span key={isDark ? 'd' : 'l'} className="flex animate-fade-in">
|
||||||
key={theme}
|
|
||||||
initial={{ rotate: -90, opacity: 0, scale: 0.5 }}
|
|
||||||
animate={{ rotate: 0, opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="flex"
|
|
||||||
>
|
|
||||||
{isDark ? (
|
{isDark ? (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<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" />
|
<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" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</motion.span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Article } from '../../api/articles';
|
import type { Article } from '../../api/articles';
|
||||||
|
import { formatRelative, readingTimeLabel } from '../../lib/format';
|
||||||
|
|
||||||
interface FocusCardProps {
|
interface FocusCardProps {
|
||||||
article: Article;
|
article: Article;
|
||||||
@@ -6,55 +7,55 @@ interface FocusCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FocusCard({ article, isTop = false }: FocusCardProps) {
|
export function FocusCard({ article, isTop = false }: FocusCardProps) {
|
||||||
const timeToRead = article.content ? Math.max(1, Math.ceil(article.content.length / 1000)) : 1;
|
const date = article.published_at || article.created_at;
|
||||||
const excerpt = article.summary ? article.summary.replace(/<[^>]*>?/gm, '').slice(0, 150) : '';
|
|
||||||
|
|
||||||
return (
|
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">
|
<article className="w-full h-full bg-carbon-light rounded-3xl overflow-hidden flex flex-col relative select-none">
|
||||||
{/* Cover (60%) */}
|
{/* Cover */}
|
||||||
<div className="h-[58%] relative overflow-hidden">
|
<div className="h-[52%] relative overflow-hidden bg-carbon-dark shrink-0">
|
||||||
{article.image_url ? (
|
{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">
|
<div className="w-full h-full bg-gradient-to-br from-nature to-nature-dark p-8 flex items-end">
|
||||||
<h2 className="text-3xl font-serif italic text-white/95 leading-tight line-clamp-4 drop-shadow">
|
<span className="text-2xl font-serif italic text-on-nature/95 leading-tight line-clamp-3">{article.feed_title}</span>
|
||||||
{article.title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</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/55 text-white text-[11px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full">
|
||||||
<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">
|
{readingTimeLabel(article.reading_time)}
|
||||||
~{timeToRead} min
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body (40%) */}
|
{/* Body */}
|
||||||
<div className="flex-1 p-6 flex flex-col">
|
<div className="flex-1 p-5 sm:p-6 flex flex-col min-h-0">
|
||||||
<div className="flex items-center justify-between mb-3 text-[10px] uppercase tracking-widest font-bold text-paper-muted/70">
|
<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">
|
<span className="flex items-center gap-2 text-nature truncate">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-nature" />
|
<span className="w-1.5 h-1.5 rounded-full bg-nature shrink-0" aria-hidden="true" />
|
||||||
{article.feed_title || 'Journal'}
|
<span className="truncate">{article.feed_title || 'Journal'}</span>
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
|
|
||||||
weekday: 'short', hour: '2-digit', minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
|
<time dateTime={date} className="shrink-0">{formatRelative(date)}</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{article.image_url && (
|
<h2 className="text-xl sm:text-2xl font-serif text-paper-white leading-tight mb-3 line-clamp-3 text-balance">
|
||||||
<h2 className="text-xl sm:text-2xl font-serif italic text-paper-white leading-tight mb-3 line-clamp-2">
|
{article.title}
|
||||||
{article.title}
|
</h2>
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{excerpt && (
|
{(article.ai_summary || article.excerpt) && (
|
||||||
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">{excerpt}…</p>
|
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">
|
||||||
|
{article.ai_summary || article.excerpt}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTop && (
|
{isTop && (
|
||||||
<p className="mt-auto pt-4 text-center text-[10px] uppercase tracking-widest font-bold text-paper-muted/40 animate-pulse">
|
<p className="mt-auto pt-3 text-center text-[11px] uppercase tracking-widest font-bold text-paper-muted hidden sm:block">
|
||||||
Double-clic pour lire · Glisser pour trier
|
← Garder · Lu → · Entrée pour lire
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,205 +1,216 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useDrag } from '@use-gesture/react';
|
import { AnimatePresence, motion, useMotionValue, useTransform, type PanInfo } from 'framer-motion';
|
||||||
import { motion, AnimatePresence, useMotionValue, useTransform, useSpring } from 'framer-motion';
|
|
||||||
import type { Article } from '../../api/articles';
|
import type { Article } from '../../api/articles';
|
||||||
|
import { useArticleActions } from '../../hooks/useArticleActions';
|
||||||
import { FocusCard } from './FocusCard';
|
import { FocusCard } from './FocusCard';
|
||||||
import { ReaderView } from '../ReaderView';
|
import { Reader } from '../reader/Reader';
|
||||||
import { MobileReaderView } from '../MobileReaderView';
|
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
|
||||||
|
|
||||||
interface FocusCardStackProps {
|
interface FocusCardStackProps {
|
||||||
articles: Article[];
|
articles: Article[];
|
||||||
onMarkRead: (id: string) => void;
|
|
||||||
onKeep: (id: string) => void;
|
|
||||||
onToggleFavorite: (id: string) => void;
|
|
||||||
onEmpty: () => void;
|
onEmpty: () => void;
|
||||||
|
onExit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Action = 'read' | 'keep';
|
||||||
|
|
||||||
interface SwipeableCardProps {
|
interface SwipeableCardProps {
|
||||||
article: Article;
|
article: Article;
|
||||||
index: number;
|
index: number;
|
||||||
isTop: boolean;
|
isTop: boolean;
|
||||||
onAction: (action: 'read' | 'keep') => void;
|
onAction: (action: Action) => void;
|
||||||
onOpen: () => void;
|
|
||||||
hasDraggedRef: React.MutableRefObject<boolean>;
|
|
||||||
isProcessing: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single draggable card. Drag state is local so unmount never "rebounds" the stack. */
|
/** A single draggable card (framer-motion drag; no extra gesture library). */
|
||||||
function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef, isProcessing }: SwipeableCardProps) {
|
function SwipeableCard({ article, index, isTop, onAction }: SwipeableCardProps) {
|
||||||
const x = useMotionValue(0);
|
const x = useMotionValue(0);
|
||||||
const rotate = useTransform(x, [-200, 200], [-10, 10]);
|
const rotate = useTransform(x, [-220, 220], [-9, 9]);
|
||||||
const opacityRead = useTransform(x, [0, 150], [0, 1]);
|
const opacityRead = useTransform(x, [20, 140], [0, 1]);
|
||||||
const opacityKeep = useTransform(x, [-150, 0], [1, 0]);
|
const opacityKeep = useTransform(x, [-140, -20], [1, 0]);
|
||||||
|
|
||||||
const xSpring = useSpring(x, { stiffness: 1000, damping: 50 });
|
const onDragEnd = (_: unknown, info: PanInfo) => {
|
||||||
const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 });
|
const { offset, velocity } = info;
|
||||||
|
if (Math.abs(offset.x) > 160 || (Math.abs(velocity.x) > 600 && Math.abs(offset.x) > 40)) {
|
||||||
const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => {
|
onAction(offset.x > 0 ? 'read' : 'keep');
|
||||||
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 scale = 1 - index * 0.05;
|
const scale = 1 - index * 0.05;
|
||||||
const yOffset = index * 18;
|
const y = index * 16;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 rounded-3xl origin-bottom"
|
className={`absolute inset-0 rounded-3xl origin-bottom ${isTop ? 'cursor-grab active:cursor-grabbing touch-pan-y' : ''}`}
|
||||||
style={{
|
style={{ x, rotate, zIndex: 100 - index, boxShadow: 'var(--shadow-float)' }}
|
||||||
x: isTop ? xSpring : 0,
|
drag={isTop ? 'x' : false}
|
||||||
rotate: isTop ? rotateSpring : 0,
|
dragSnapToOrigin
|
||||||
zIndex: 100 - index,
|
dragElastic={0.9}
|
||||||
cursor: isTop ? 'grab' : 'default',
|
onDragEnd={onDragEnd}
|
||||||
boxShadow: 'var(--shadow-float)',
|
initial={{ scale, y }}
|
||||||
}}
|
animate={{ scale, y }}
|
||||||
{...(isTop ? (bind() as Record<string, unknown>) : {})}
|
exit={{ opacity: 0, scale: 0.92, transition: { duration: 0.18 } }}
|
||||||
onDoubleClick={() => { if (isTop && !hasDraggedRef.current) onOpen(); }}
|
aria-hidden={!isTop}
|
||||||
initial={{ scale, y: yOffset, x: 0 }}
|
|
||||||
animate={{ scale, y: yOffset, x: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
|
|
||||||
>
|
>
|
||||||
{/* Tinder-style stamps */}
|
{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/40 backdrop-blur-sm">
|
<motion.div style={{ opacity: opacityRead }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-nature/15">
|
||||||
J'ai lu
|
<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">
|
||||||
</div>
|
Lu
|
||||||
</motion.div>
|
</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">
|
</motion.div>
|
||||||
<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">
|
<motion.div style={{ opacity: opacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-earth/15">
|
||||||
Garder
|
<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">
|
||||||
</div>
|
Garder
|
||||||
</motion.div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<FocusCard article={article} isTop={isTop} />
|
<FocusCard article={article} isTop={isTop} />
|
||||||
</motion.div>
|
</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 [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [readingArticle, setReadingArticle] = useState<Article | null>(null);
|
const [reading, setReading] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
// History of actions so "undo" also restores the unread state on the server.
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const history = useRef<Action[]>([]);
|
||||||
const hasDraggedRef = useRef(false);
|
const [announce, setAnnounce] = useState('');
|
||||||
|
|
||||||
const visibleArticles = articles.slice(currentIndex, currentIndex + 3);
|
const top = articles[currentIndex];
|
||||||
const topArticle = visibleArticles[0];
|
const visible = articles.slice(currentIndex, currentIndex + 3);
|
||||||
|
|
||||||
const handleAction = (action: 'read' | 'keep' | 'undo') => {
|
const act = useCallback(
|
||||||
if (isProcessing) return;
|
(action: Action) => {
|
||||||
setIsProcessing(true);
|
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') {
|
const undo = useCallback(() => {
|
||||||
setCurrentIndex((prev) => Math.max(0, prev - 1));
|
if (currentIndex === 0) return;
|
||||||
} else if (action === 'read') {
|
const last = history.current.pop();
|
||||||
if (topArticle) onMarkRead(topArticle.id);
|
const prev = articles[currentIndex - 1];
|
||||||
setCurrentIndex((prev) => prev + 1);
|
if (last === 'read' && prev) setRead(prev, false);
|
||||||
} else {
|
setAnnounce('Action annulée');
|
||||||
if (topArticle) onKeep(topArticle.id);
|
setCurrentIndex((i) => Math.max(0, i - 1));
|
||||||
setCurrentIndex((prev) => prev + 1);
|
}, [articles, currentIndex, setRead]);
|
||||||
}
|
|
||||||
setTimeout(() => setIsProcessing(false), 450);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentIndex >= articles.length && articles.length > 0) onEmpty();
|
if (currentIndex >= articles.length && articles.length > 0) onEmpty();
|
||||||
}, [currentIndex, articles.length, 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 (
|
return (
|
||||||
<div className="relative w-full h-full flex items-center justify-center overflow-hidden">
|
<div className="relative w-full h-full flex flex-col items-center justify-center gap-6">
|
||||||
<AnimatePresence>
|
<p className="sr-only" aria-live="polite">{announce}</p>
|
||||||
{readingArticle && (
|
|
||||||
isMobile ? (
|
<span className="inline-block bg-nature-dark/80 px-4 py-2 rounded-full text-white text-[11px] uppercase font-bold tracking-widest">
|
||||||
<MobileReaderView
|
{articles.length - currentIndex} restant{articles.length - currentIndex > 1 ? 's' : ''}
|
||||||
article={readingArticle}
|
</span>
|
||||||
onClose={() => setReadingArticle(null)}
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
|
||||||
onNext={() => { handleAction('read'); setReadingArticle(null); }}
|
|
||||||
onPrev={() => { handleAction('keep'); setReadingArticle(null); }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ReaderView article={readingArticle} onClose={() => setReadingArticle(null)} onToggleFavorite={onToggleFavorite} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Stack */}
|
{/* Stack */}
|
||||||
<div className="relative w-full max-w-lg aspect-[3/4] sm:aspect-[4/5] md:h-[600px] z-10">
|
<div className="relative w-full max-w-md flex-1 max-h-[560px] min-h-[340px] mb-8">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
{visibleArticles.map((article, i) => (
|
{visible.map((article, i) => (
|
||||||
<SwipeableCard
|
<SwipeableCard key={article.id} article={article} index={i} isTop={i === 0} onAction={act} />
|
||||||
key={article.id}
|
|
||||||
article={article}
|
|
||||||
index={i}
|
|
||||||
isTop={i === 0}
|
|
||||||
onAction={handleAction}
|
|
||||||
onOpen={() => setReadingArticle(article)}
|
|
||||||
hasDraggedRef={hasDraggedRef}
|
|
||||||
isProcessing={isProcessing}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls (every gesture has a button equivalent) */}
|
||||||
<div className="absolute bottom-10 left-0 right-0 flex justify-center items-center gap-4 sm:gap-8 px-4 z-20">
|
<div className="flex justify-center items-center gap-3 sm:gap-5 pb-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('keep')}
|
onClick={() => act('keep')}
|
||||||
disabled={isProcessing}
|
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"
|
||||||
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 pour plus tard (←)"
|
||||||
title="Garder (gauche)" aria-label="Garder"
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('undo')}
|
onClick={undo}
|
||||||
disabled={currentIndex === 0 || isProcessing}
|
disabled={currentIndex === 0}
|
||||||
className={`p-3 rounded-full border shadow-lg transition-all ${
|
className="icon-btn bg-carbon-light shadow"
|
||||||
currentIndex > 0
|
title="Annuler (u)"
|
||||||
? 'bg-carbon-light border-nature/15 text-nature hover:bg-nature hover:text-white hover:scale-105 opacity-100'
|
aria-label="Annuler la dernière action"
|
||||||
: 'opacity-0 pointer-events-none scale-90'
|
|
||||||
}`}
|
|
||||||
title="Annuler" aria-label="Annuler"
|
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => setReading(true)} className="btn-secondary bg-carbon-light" title="Lire l’article (Entrée)">
|
||||||
|
Lire
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('read')}
|
onClick={() => act('read')}
|
||||||
disabled={isProcessing}
|
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"
|
||||||
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="Marquer comme lu (→)"
|
||||||
title="J'ai lu (droite)" aria-label="J'ai 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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Counter */}
|
{reading && (
|
||||||
<div className="absolute top-10 left-0 right-0 text-center z-20 pointer-events-none">
|
<Reader
|
||||||
<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">
|
key={top.id}
|
||||||
{articles.length - currentIndex} restants
|
article={top}
|
||||||
</span>
|
next={articles[currentIndex + 1]}
|
||||||
</div>
|
hasPrev={false}
|
||||||
|
onClose={() => setReading(false)}
|
||||||
|
onNext={() => {
|
||||||
|
act('read');
|
||||||
|
if (!articles[currentIndex + 1]) setReading(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,32 +1,25 @@
|
|||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface FocusEmptyStateProps {
|
interface FocusEmptyStateProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FocusEmptyState({ onBack }: FocusEmptyStateProps) {
|
export function FocusEmptyState({ onBack }: FocusEmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="flex-1 flex flex-col items-center justify-center text-center space-y-8 px-6 animate-rise">
|
||||||
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="relative">
|
<div className="relative">
|
||||||
<div className="w-28 h-28 rounded-full bg-nature/10 flex items-center justify-center animate-bounce-slow">
|
<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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-4xl font-serif italic text-paper-white">C'est tout pour le moment !</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button onClick={onBack} className="btn-primary py-3 px-8">Retour au tableau de bord</button>
|
<button onClick={onBack} className="btn-primary py-3 px-8" autoFocus>Retour au tableau de bord</button>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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() {
|
export function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches);
|
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
/** True when the user asked the OS to minimise motion. */
|
||||||
const media = window.matchMedia('(max-width: 768px)');
|
export function prefersReducedMotion() {
|
||||||
const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
media.addEventListener('change', listener);
|
|
||||||
return () => media.removeEventListener('change', listener);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return isMobile;
|
|
||||||
}
|
}
|
||||||
@@ -1,51 +1,98 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
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() {
|
export function useWebsocket() {
|
||||||
const queryClient = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const socketRef = useRef<WebSocket | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
let socket: WebSocket | null = null;
|
||||||
const host = window.location.host;
|
let retry = 0;
|
||||||
const wsUrl = `${protocol}//${host}/api/v1/ws`;
|
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 connect = () => {
|
||||||
const socket = new WebSocket(wsUrl);
|
if (closed) return;
|
||||||
socketRef.current = socket;
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
socket = new WebSocket(`${protocol}//${window.location.host}/api/v1/ws`);
|
||||||
socket.onmessage = (event) => {
|
socket.onopen = () => {
|
||||||
try {
|
retry = 0;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
socket.onmessage = onMessage;
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
console.log('WS connection closed, retrying in 5s...');
|
if (closed) return;
|
||||||
setTimeout(connect, 5000);
|
const delay = Math.min(30_000, 1000 * 2 ** retry++);
|
||||||
};
|
retryTimer = window.setTimeout(connect, delay);
|
||||||
|
|
||||||
socket.onerror = (err) => {
|
|
||||||
console.error('WS error:', err);
|
|
||||||
socket.close();
|
|
||||||
};
|
};
|
||||||
|
socket.onerror = () => socket?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (socketRef.current) {
|
closed = true;
|
||||||
socketRef.current.close();
|
window.clearTimeout(retryTimer);
|
||||||
}
|
window.clearTimeout(feedsTimer);
|
||||||
|
socket?.close();
|
||||||
};
|
};
|
||||||
}, [queryClient]);
|
}, [qc]);
|
||||||
}
|
}
|
||||||
+207
-96
@@ -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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
FlowReader — Design System "Éditorial Botanique"
|
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 {
|
@theme {
|
||||||
/* Surfaces — warm paper */
|
/* Surfaces — warm paper */
|
||||||
--color-carbon: #F7F4EC; /* app background */
|
--color-carbon: #F7F4EC; /* app background */
|
||||||
@@ -16,23 +18,26 @@
|
|||||||
--color-nature: #15643A;
|
--color-nature: #15643A;
|
||||||
--color-nature-light: #1E8A4F;
|
--color-nature-light: #1E8A4F;
|
||||||
--color-nature-dark: #0A2E1B;
|
--color-nature-dark: #0A2E1B;
|
||||||
|
--color-on-nature: #FFFFFF; /* text on a nature-filled surface */
|
||||||
|
|
||||||
/* Accent — terracotta (used sparingly) */
|
/* Accent — terracotta (used sparingly) */
|
||||||
--color-earth: #B45B3E;
|
--color-earth: #A9502F;
|
||||||
--color-earth-light: #C9714F;
|
--color-earth-light: #C9714F;
|
||||||
|
--color-on-earth: #FFFFFF;
|
||||||
|
|
||||||
/* Ink */
|
/* Ink */
|
||||||
--color-paper-white: #1B1714; /* primary text */
|
--color-paper-white: #1B1714; /* primary text */
|
||||||
--color-paper-muted: #6B645B; /* secondary text */
|
--color-paper-muted: #635C53; /* secondary text (≥ 5:1 on paper) */
|
||||||
|
|
||||||
/* Semantic */
|
/* Semantic */
|
||||||
--color-ring: #1E8A4F;
|
--color-ring: #1E8A4F;
|
||||||
--color-danger: #C2410C;
|
--color-danger: #B3390A;
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--font-serif: "Playfair Display", Georgia, serif;
|
--font-serif: "Playfair Display Variable", "Playfair Display", Georgia, serif;
|
||||||
--font-sans: "Inter", system-ui, sans-serif;
|
--font-sans: "Inter Variable", "Inter", system-ui, sans-serif;
|
||||||
--font-reading: "Lora", Georgia, serif;
|
--font-reading: "Lora Variable", "Lora", Georgia, serif;
|
||||||
|
--font-legible: "Atkinson Hyperlegible", "Inter Variable", system-ui, sans-serif;
|
||||||
|
|
||||||
/* Radii scale */
|
/* Radii scale */
|
||||||
--radius-xs: 0.375rem;
|
--radius-xs: 0.375rem;
|
||||||
@@ -41,29 +46,47 @@
|
|||||||
--radius-lg: 1.25rem;
|
--radius-lg: 1.25rem;
|
||||||
--radius-xl: 1.75rem;
|
--radius-xl: 1.75rem;
|
||||||
|
|
||||||
--line-height-magazine: 1.85;
|
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-soft: 0 2px 8px -2px rgba(27, 23, 20, 0.08), 0 4px 24px -6px rgba(27, 23, 20, 0.06);
|
--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-lift: 0 12px 32px -12px rgba(27, 23, 20, 0.18);
|
||||||
--shadow-float: 0 24px 60px -20px rgba(10, 46, 27, 0.28);
|
--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 {
|
.dark {
|
||||||
--color-carbon: #0A0F0B;
|
--color-carbon: #0E130F;
|
||||||
--color-carbon-light: #12190F;
|
--color-carbon-light: #161D17;
|
||||||
--color-carbon-dark: #060A06;
|
--color-carbon-dark: #090D0A;
|
||||||
|
|
||||||
--color-nature: #34D27B;
|
--color-nature: #34D27B;
|
||||||
--color-nature-light: #5DE89A;
|
--color-nature-light: #5DE89A;
|
||||||
--color-nature-dark: #0E1C12;
|
--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-earth-light: #E59A7E;
|
||||||
|
--color-on-earth: #2A0F05;
|
||||||
|
|
||||||
--color-paper-white: #F4F1E8;
|
--color-paper-white: #E9E5DA;
|
||||||
--color-paper-muted: #9A958A;
|
--color-paper-muted: #A39E92;
|
||||||
|
|
||||||
--color-ring: #34D27B;
|
--color-ring: #34D27B;
|
||||||
--color-danger: #F87045;
|
--color-danger: #F87045;
|
||||||
@@ -76,13 +99,6 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
Custom utilities
|
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 {
|
@utility paper-panel {
|
||||||
background: var(--color-carbon-light);
|
background: var(--color-carbon-light);
|
||||||
border: 1px solid color-mix(in oklab, var(--color-paper-muted) 16%, transparent);
|
border: 1px solid color-mix(in oklab, var(--color-paper-muted) 16%, transparent);
|
||||||
@@ -93,6 +109,16 @@
|
|||||||
text-wrap: balance;
|
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
|
Base
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -105,7 +131,6 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -113,19 +138,9 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
border-top: 3px solid var(--color-nature);
|
border-top: 3px solid var(--color-nature);
|
||||||
background-image:
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 8%, transparent), transparent);
|
||||||
radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 8%, transparent), transparent),
|
background-repeat: no-repeat;
|
||||||
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");
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
@@ -133,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@apply transition-colors duration-300;
|
@apply transition-colors duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accessible focus — keyboard only */
|
/* Accessible focus — keyboard only */
|
||||||
@@ -174,50 +189,64 @@
|
|||||||
@layer components {
|
@layer components {
|
||||||
/* Eyebrow / kicker label */
|
/* Eyebrow / kicker label */
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
@apply text-[10px] uppercase tracking-[0.35em] font-bold text-nature;
|
@apply text-[11px] uppercase tracking-[0.3em] font-bold text-nature;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply inline-flex items-center justify-center gap-2 bg-nature text-white font-bold
|
@apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-bold
|
||||||
px-6 py-2.5 rounded-full text-[11px] uppercase tracking-[0.18em]
|
min-h-11 px-6 py-2.5 rounded-full text-xs uppercase tracking-[0.16em]
|
||||||
border border-transparent transition-all duration-300
|
border border-transparent transition-all duration-200
|
||||||
hover:bg-nature-light hover:shadow-[0_8px_24px_-8px_rgba(21,100,58,0.5)]
|
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;
|
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-bold
|
@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]
|
min-h-11 px-5 py-2.5 rounded-full text-xs uppercase tracking-[0.16em]
|
||||||
border border-nature/30 transition-all duration-300
|
border border-nature/35 transition-all duration-200
|
||||||
hover:bg-nature hover:text-white hover:border-nature
|
hover:bg-nature hover:text-on-nature hover:border-nature
|
||||||
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
|
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply inline-flex items-center justify-center gap-2 text-paper-muted font-medium
|
@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;
|
hover:text-nature hover:bg-nature/5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
@apply inline-flex items-center justify-center w-9 h-9 rounded-full text-nature
|
@apply inline-flex items-center justify-center w-11 h-11 shrink-0 rounded-full text-nature
|
||||||
border border-nature/15 transition-all duration-300
|
border border-nature/20 transition-all duration-200
|
||||||
hover:bg-nature/10 hover:border-nature/30 active:scale-95;
|
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 */
|
/* Inputs */
|
||||||
.input-field {
|
.input-field {
|
||||||
@apply w-full bg-carbon-light text-paper-white px-4 py-3 rounded-xl
|
@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
|
border border-paper-muted/25 shadow-sm transition-all duration-200
|
||||||
placeholder:text-paper-muted/45
|
placeholder:text-paper-muted
|
||||||
focus:border-nature/50 focus:ring-2 focus:ring-nature/25 outline-none;
|
focus:border-nature/50 focus:ring-2 focus:ring-nature/25 outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chip / badge */
|
/* Chip / badge */
|
||||||
.chip {
|
.chip {
|
||||||
@apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full
|
@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;
|
bg-nature/10 text-nature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,44 +256,128 @@
|
|||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Magazine reading content ---------- */
|
/* Keyboard hint */
|
||||||
.magazine-content {
|
.kbd {
|
||||||
@apply font-reading text-paper-white/90;
|
@apply inline-flex items-center justify-center min-w-6 h-6 px-1.5 rounded-md border border-paper-muted/30
|
||||||
line-height: var(--line-height-magazine);
|
bg-carbon-light text-[11px] font-mono font-semibold text-paper-white shadow-sm;
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drop cap for immersive reader */
|
/* ---------- Article body ----------
|
||||||
.drop-cap::first-letter {
|
Driven by reader preferences via CSS variables set on the reader:
|
||||||
@apply font-serif text-nature float-left mr-3 mt-2;
|
--reader-size, --reader-leading, --reader-font. Values follow
|
||||||
font-size: 4.5rem;
|
long-form reading guidance (50–75 chars, 1.5–1.7 line-height). */
|
||||||
line-height: 3rem;
|
.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
|
Keyframes & motion utilities
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@layer 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 { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
.animate-fade-in { animation: fade-in 0.5s ease-out forwards; }
|
.animate-fade-in { animation: fade-in 0.35s ease-out both; }
|
||||||
.animate-rise { animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
|
.animate-rise { animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||||
.animate-bounce-slow { animation: bounce-slow 3s infinite; }
|
.animate-bounce-slow { animation: bounce-slow 3s infinite; }
|
||||||
.animate-shake { animation: shake 0.4s ease-in-out; }
|
.animate-shake { animation: shake 0.4s ease-in-out; }
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@keyframes bounce-slow {
|
@keyframes bounce-slow {
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { useState } from 'react';
|
import { lazy, Suspense, useState } from 'react';
|
||||||
import { Sidebar } from '../components/Sidebar';
|
import { Sidebar } from '../components/Sidebar';
|
||||||
import { MobileTopBar } from '../components/MobileTopBar';
|
import { MobileTopBar } from '../components/MobileTopBar';
|
||||||
import { DashboardPage } from '../pages/DashboardPage';
|
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() {
|
export function RootLayout() {
|
||||||
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
|
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
|
||||||
const [isFocusMode, setIsFocusMode] = useState(false);
|
const [isFocusMode, setIsFocusMode] = useState(false);
|
||||||
|
|
||||||
|
// One socket for the whole session (not per page mount).
|
||||||
|
useWebsocket();
|
||||||
|
|
||||||
return (
|
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
|
<Sidebar
|
||||||
onSelectFeed={setSelectedFeedId}
|
onSelectFeed={setSelectedFeedId}
|
||||||
selectedFeedId={selectedFeedId}
|
selectedFeedId={selectedFeedId}
|
||||||
@@ -17,17 +26,28 @@ export function RootLayout() {
|
|||||||
isFocusMode={isFocusMode}
|
isFocusMode={isFocusMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isFocusMode ? (
|
<div id="main-content" className="flex-1 flex flex-col min-w-0">
|
||||||
<FocusPage onExit={() => setIsFocusMode(false)} />
|
{!isFocusMode && (
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
|
||||||
<MobileTopBar
|
<MobileTopBar
|
||||||
onSelectFeed={setSelectedFeedId}
|
onSelectFeed={setSelectedFeedId}
|
||||||
selectedFeedId={selectedFeedId}
|
selectedFeedId={selectedFeedId}
|
||||||
onEnterFocus={() => setIsFocusMode(true)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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
@@ -1,10 +1,25 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { registerSW } from 'virtual:pwa-register'
|
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 './index.css'
|
||||||
import App from './App.tsx'
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
+347
-205
@@ -1,214 +1,331 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import Masonry from 'react-masonry-css';
|
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { articlesApi } from '../api/articles';
|
import { articlesApi, cursorAfter, type Article } from '../api/articles';
|
||||||
import { feedsApi } from '../api/feeds';
|
import { feedsApi } from '../api/feeds';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { ReaderView } from '../components/ReaderView';
|
import { Reader } from '../components/reader/Reader';
|
||||||
import { MobileReaderView } from '../components/MobileReaderView';
|
import { SearchBox } from '../components/SearchBox';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { ShortcutsHelp } from '../components/ShortcutsHelp';
|
||||||
import { useWebsocket } from '../hooks/useWebsocket';
|
import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions';
|
||||||
import type { Article } from '../api/articles';
|
import { useLive } from '../hooks/useWebsocket';
|
||||||
|
import { prefersReducedMotion } from '../hooks/useIsMobile';
|
||||||
|
import { usePrefs } from '../stores/prefsStore';
|
||||||
|
|
||||||
const breakpointColumnsObj = { default: 3, 1100: 2, 700: 1 };
|
const PAGE_SIZE = 30;
|
||||||
const ITEMS_PER_PAGE = 24;
|
|
||||||
|
|
||||||
interface DashboardPageProps {
|
interface DashboardPageProps {
|
||||||
selectedFeedId: string | null;
|
selectedFeedId: string | null;
|
||||||
onEnterFocus: () => void;
|
onEnterFocus: () => void;
|
||||||
|
/** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArticlePages = { pages: Article[][] };
|
function isTypingTarget(t: EventTarget | null) {
|
||||||
|
const el = t as HTMLElement | null;
|
||||||
function timestamp(a: Article): number {
|
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
||||||
return new Date(a.published_at || a.created_at).getTime();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) {
|
export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: DashboardPageProps) {
|
||||||
const queryClient = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
const [params, setParams] = useSearchParams();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
||||||
const mainRef = useRef<HTMLElement>(null);
|
const mainRef = useRef<HTMLElement>(null);
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const unreadPref = usePrefs((s) => s.unreadOnly);
|
||||||
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 400);
|
const setUnreadOnly = usePrefs((s) => s.setUnreadOnly);
|
||||||
return () => clearTimeout(timer);
|
const newCount = useLive((s) => s.newCount);
|
||||||
}, [searchQuery]);
|
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({
|
const { data, isPending, isError, refetch, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, isPlaceholderData } =
|
||||||
queryKey,
|
useInfiniteQuery({
|
||||||
queryFn: ({ pageParam = 0 }) => {
|
queryKey: listKey,
|
||||||
const options = { limit: ITEMS_PER_PAGE, offset: pageParam };
|
queryFn: ({ pageParam }) =>
|
||||||
if (debouncedQuery) return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam);
|
searching
|
||||||
if (selectedFeedId === 'favorites') return articlesApi.list({ ...options, favorite: true });
|
? articlesApi.search(query, PAGE_SIZE, Number(pageParam) || 0)
|
||||||
return articlesApi.list({ ...options, feed_id: selectedFeedId || undefined });
|
: articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }),
|
||||||
},
|
initialPageParam: '' as string,
|
||||||
initialPageParam: 0,
|
getNextPageParam: (last, all) => {
|
||||||
getNextPageParam: (lastPage, allPages) =>
|
if (last.length < PAGE_SIZE) return undefined;
|
||||||
lastPage.length < ITEMS_PER_PAGE ? undefined : allPages.length * ITEMS_PER_PAGE,
|
return searching ? String(all.length * PAGE_SIZE) : cursorAfter(last[last.length - 1]);
|
||||||
});
|
},
|
||||||
|
// Keep showing the previous list while a new filter/search loads.
|
||||||
const sortedArticles = useMemo(() => {
|
placeholderData: keepPreviousData,
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
|
const target = loadMoreRef.current;
|
||||||
|
if (!target) return;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
|
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();
|
return () => observer.disconnect();
|
||||||
}, [hasNextPage, fetchNextPage, isFetchingNextPage]);
|
}, [hasNextPage, fetchNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
// Back-to-top visibility
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = mainRef.current;
|
const el = mainRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const onScroll = () => setShowScrollTop(el.scrollTop > 900);
|
const onScroll = () => setShowScrollTop(el.scrollTop > 900);
|
||||||
el.addEventListener('scroll', onScroll);
|
el.addEventListener('scroll', onScroll, { passive: true });
|
||||||
return () => el.removeEventListener('scroll', onScroll);
|
return () => el.removeEventListener('scroll', onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshMutation = useMutation({
|
const scrollTop = () => mainRef.current?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||||
mutationFn: () => feedsApi.refresh(),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleReadMutation = useMutation({
|
const refreshMutation = useMutation({ mutationFn: () => feedsApi.refresh() });
|
||||||
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
|
|
||||||
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
|
const markAllMutation = useMutation({
|
||||||
onMutate: async ({ id, is_read }) => {
|
mutationFn: () => (feedId ? articlesApi.markAllRead(feedId) : articlesApi.markAllReadGlobal()),
|
||||||
await queryClient.cancelQueries({ queryKey });
|
onSuccess: () => {
|
||||||
const previousData = queryClient.getQueryData(queryKey);
|
qc.invalidateQueries({ queryKey: ['articles'] });
|
||||||
queryClient.setQueryData(queryKey, (old: ArticlePages | undefined) => {
|
qc.invalidateQueries({ queryKey: ['feeds'] });
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => page.map((a) => (a.id === id ? { ...a, is_read } : a))),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { previousData };
|
|
||||||
},
|
},
|
||||||
onError: (_e, _v, context) => {
|
|
||||||
if (context?.previousData) queryClient.setQueryData(queryKey, context.previousData);
|
|
||||||
},
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleFavoriteMutation = useMutation({
|
const showNew = () => {
|
||||||
mutationFn: articlesApi.toggleFavorite,
|
clearNew();
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
qc.invalidateQueries({ queryKey: ['articles'] });
|
||||||
});
|
scrollTop();
|
||||||
|
};
|
||||||
|
|
||||||
const heading = debouncedQuery
|
// ---------- Keyboard navigation on the list ----------
|
||||||
? `Résultats : « ${debouncedQuery} »`
|
useEffect(() => {
|
||||||
: selectedFeedId === 'favorites'
|
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'
|
? 'Mes favoris'
|
||||||
: selectedFeedId
|
: feedTitle ?? 'La Une';
|
||||||
? '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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon relative">
|
<main ref={mainRef} className={`flex-1 overflow-y-auto bg-carbon relative ${hidden ? 'hidden' : ''}`} aria-busy={isFetching}>
|
||||||
{/* Back to top */}
|
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-12 py-8 md:py-12">
|
||||||
<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">
|
|
||||||
{/* Masthead */}
|
{/* Masthead */}
|
||||||
<header className="mb-12 border-b border-paper-muted/15 pb-8">
|
<header className="mb-8 border-b border-paper-muted/15 pb-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
|
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-5">
|
||||||
<div>
|
<div className="min-w-0 flex-1 basis-72">
|
||||||
<p className="eyebrow mb-2">Édition du jour</p>
|
<p className="eyebrow mb-2">{searching ? 'Recherche' : 'Édition du jour'}</p>
|
||||||
<h1 className="text-5xl md:text-6xl font-serif italic text-paper-white tracking-tight text-balance">
|
<h1 className="text-4xl md:text-6xl font-serif italic text-paper-white tracking-tight text-balance break-words">
|
||||||
{heading}
|
{heading}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-paper-muted text-sm mt-3 font-reading">
|
<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', year: 'numeric' })}
|
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||||
{unreadCount > 0 && <span className="text-nature"> · {unreadCount} non lus</span>}
|
{!searching && !favorites && (
|
||||||
|
<span className="text-nature font-medium"> · {unreadTotal} non lu{unreadTotal > 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3 w-full sm:w-auto">
|
||||||
<div className="relative group">
|
<div className="w-full sm:w-auto order-last sm:order-none">
|
||||||
<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">
|
<SearchBox ref={searchRef} onSearch={setQuery} />
|
||||||
<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>
|
</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
|
<button
|
||||||
onClick={() => refreshMutation.mutate()}
|
onClick={() => refreshMutation.mutate()}
|
||||||
disabled={refreshMutation.isPending}
|
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" />
|
<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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 */}
|
{/* Focus CTA */}
|
||||||
{!selectedFeedId && !debouncedQuery && unreadCount > 0 && (
|
{!feedId && !favorites && !searching && unreadTotal > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={onEnterFocus}
|
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="relative z-10">
|
||||||
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-white/70">Mode Focus</span>
|
<span className="text-[11px] uppercase tracking-[0.3em] font-bold opacity-85">Mode Focus</span>
|
||||||
<span className="block text-2xl font-serif italic mt-1">Votre session de lecture</span>
|
<span className="block text-xl sm: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="block opacity-90 text-sm mt-1">{unreadTotal} articles non lus à trier d'un geste.</span>
|
||||||
</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
|
Lancer
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<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" />
|
<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 */}
|
{/* 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">
|
<div
|
||||||
{sortedArticles.map((article) => (
|
className={`grid gap-6 sm:grid-cols-2 xl:grid-cols-3 transition-opacity ${isPlaceholderData ? 'opacity-60' : ''}`}
|
||||||
<div key={article.id} className="mb-6">
|
>
|
||||||
<ArticleCard
|
{articles.map((article, i) => (
|
||||||
article={article}
|
<ArticleCard
|
||||||
onClick={(a) => {
|
key={article.id}
|
||||||
setSelectedArticle(a);
|
index={i}
|
||||||
if (!a.is_read) toggleReadMutation.mutate({ id: a.id, is_read: true });
|
article={article}
|
||||||
}}
|
onOpen={openReader}
|
||||||
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
|
onToggleRead={toggleRead}
|
||||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
onToggleFavorite={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</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 ? (
|
{isFetchingNextPage ? (
|
||||||
<>
|
<div className="w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" role="status" aria-label="Chargement" />
|
||||||
<div className="w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
|
) : !hasNextPage ? (
|
||||||
<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="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="h-px w-16 bg-nature/20" />
|
<span className="h-px w-16 bg-nature/25" />
|
||||||
<p className="eyebrow text-paper-muted/50">Fin de l'édition</p>
|
<p className="eyebrow text-paper-muted">Fin de l'édition</p>
|
||||||
<span className="h-px w-16 bg-nature/20" />
|
<span className="h-px w-16 bg-nature/25" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</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">
|
<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-muted font-serif italic text-xl mb-2">
|
<p className="text-paper-white font-serif italic text-2xl">
|
||||||
{debouncedQuery ? `Aucun article pour « ${debouncedQuery} ».` : 'Votre bibliothèque est vide.'}
|
{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>
|
||||||
<p className="eyebrow text-paper-muted/50">
|
<p className="text-paper-muted text-sm">
|
||||||
{debouncedQuery ? 'Tentez une recherche plus large.' : 'Ajoutez un flux pour commencer.'}
|
{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>
|
</p>
|
||||||
|
{unreadOnly && !searching && (feeds?.length ?? 0) > 0 && (
|
||||||
|
<button onClick={() => setUnreadOnly(false)} className="btn-secondary mt-2">Voir tous les articles</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedArticle && (
|
{/* Back to top */}
|
||||||
isMobile ? (
|
<button
|
||||||
<MobileReaderView
|
onClick={scrollTop}
|
||||||
article={selectedArticle}
|
className={`fixed bottom-6 right-6 z-40 icon-btn !w-12 !h-12 bg-carbon-light shadow-lg transition-all duration-300 ${
|
||||||
onClose={() => setSelectedArticle(null)}
|
showScrollTop ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'
|
||||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
}`}
|
||||||
onNext={() => {
|
aria-label="Retour en haut"
|
||||||
const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
|
tabIndex={showScrollTop ? 0 : -1}
|
||||||
if (idx < sortedArticles.length - 1) {
|
>
|
||||||
const next = sortedArticles[idx + 1];
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
setSelectedArticle(next);
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||||
if (!next.is_read) toggleReadMutation.mutate({ id: next.id, is_read: true });
|
</svg>
|
||||||
} else if (hasNextPage) {
|
</button>
|
||||||
fetchNextPage();
|
|
||||||
} else {
|
{openArticle && !hidden && (
|
||||||
setSelectedArticle(null);
|
<Reader
|
||||||
}
|
key={openArticle.id}
|
||||||
}}
|
article={openArticle}
|
||||||
onPrev={() => {
|
next={openIndex >= 0 ? articles[openIndex + 1] : undefined}
|
||||||
const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
|
hasPrev={openIndex > 0}
|
||||||
if (idx > 0) setSelectedArticle(sortedArticles[idx - 1]);
|
onClose={closeReader}
|
||||||
}}
|
onNext={openIndex >= 0 && (openIndex + 1 < articles.length || hasNextPage) ? goNext : undefined}
|
||||||
/>
|
onPrev={goPrev}
|
||||||
) : (
|
/>
|
||||||
<ReaderView
|
|
||||||
article={selectedArticle}
|
|
||||||
onClose={() => setSelectedArticle(null)}
|
|
||||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showHelp && <ShortcutsHelp onClose={() => setShowHelp(false)} />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+49
-56
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { articlesApi, type Article } from '../api/articles';
|
import { MotionConfig } from 'framer-motion';
|
||||||
|
import { articlesApi } from '../api/articles';
|
||||||
import { FocusCardStack } from '../components/focus/FocusCardStack';
|
import { FocusCardStack } from '../components/focus/FocusCardStack';
|
||||||
import { FocusEmptyState } from '../components/focus/FocusEmptyState';
|
import { FocusEmptyState } from '../components/focus/FocusEmptyState';
|
||||||
|
|
||||||
@@ -9,74 +10,66 @@ interface FocusPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FocusPage({ onExit }: FocusPageProps) {
|
export function FocusPage({ onExit }: FocusPageProps) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
|
|
||||||
const { data: articles, isLoading } = useQuery({
|
// Snapshot of unread articles for the session. Read/favorite updates are
|
||||||
queryKey: ['articles', 'focus-mode'],
|
// 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 }),
|
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,
|
// Deck order is frozen on first load (ids only; live data comes from the cache).
|
||||||
// so marking articles read mid-session never reshuffles the stack. This is
|
const [deckIds, setDeckIds] = useState<string[] | null>(null);
|
||||||
// the React-endorsed "adjust state during render" pattern (guarded to run
|
if (deckIds === null && articles) setDeckIds(articles.map((a) => a.id));
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidate = () => {
|
const byId = new Map((articles ?? []).map((a) => [a.id, a]));
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
const deck = (deckIds ?? []).map((id) => byId.get(id)).filter((a) => !!a);
|
||||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const markReadMutation = useMutation({ mutationFn: (id: string) => articlesApi.markRead(id), onSuccess: invalidate });
|
let body: React.ReactNode;
|
||||||
const toggleFavoriteMutation = useMutation({ mutationFn: (id: string) => articlesApi.toggleFavorite(id), onSuccess: invalidate });
|
if (isPending) {
|
||||||
|
body = (
|
||||||
if (isLoading && deck.length === 0) {
|
<div className="flex-1 flex items-center justify-center" role="status" aria-label="Chargement">
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full bg-carbon/60 backdrop-blur-xl">
|
|
||||||
<div className="w-14 h-14 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
|
<div className="w-14 h-14 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else if (isError) {
|
||||||
|
body = (
|
||||||
if (deck.length === 0 || isComplete) {
|
<div role="alert" className="flex-1 flex flex-col items-center justify-center gap-4 text-center">
|
||||||
return (
|
<p className="font-serif italic text-xl text-paper-white">Impossible de charger vos articles.</p>
|
||||||
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-2xl flex items-center justify-center">
|
<button onClick={() => refetch()} className="btn-secondary">Réessayer</button>
|
||||||
<FocusEmptyState onBack={onExit} />
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-2xl flex flex-col">
|
<MotionConfig reducedMotion="user">
|
||||||
<header className="absolute top-0 left-0 right-0 p-6 flex justify-between items-center z-50">
|
<div className="fixed inset-0 z-50 bg-carbon flex flex-col animate-fade-in" role="region" aria-label="Mode Focus">
|
||||||
<div className="flex items-center gap-2">
|
<header className="flex justify-between items-center p-4 sm:p-6 shrink-0">
|
||||||
<span className="w-2 h-2 rounded-full bg-earth animate-pulse" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="eyebrow text-paper-white">Mode Focus</span>
|
<span className="w-2 h-2 rounded-full bg-earth animate-pulse" aria-hidden="true" />
|
||||||
</div>
|
<span className="eyebrow text-paper-white">Mode Focus</span>
|
||||||
<button onClick={onExit} className="btn-secondary" title="Quitter le mode Focus">
|
</div>
|
||||||
<span className="hidden sm:inline">Tableau de bord</span>
|
<button onClick={onExit} className="btn-secondary" title="Quitter le mode Focus (Échap)">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<span className="hidden sm:inline">Tableau de bord</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</button>
|
</svg>
|
||||||
</header>
|
<span className="sr-only sm:hidden">Quitter</span>
|
||||||
|
</button>
|
||||||
<main className="flex-1 flex items-center justify-center p-4">
|
</header>
|
||||||
<FocusCardStack
|
{body}
|
||||||
articles={deck}
|
</div>
|
||||||
onMarkRead={(id) => markReadMutation.mutate(id)}
|
</MotionConfig>
|
||||||
onKeep={() => { /* skip — no API action */ }}
|
|
||||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
|
|
||||||
onEmpty={() => setIsComplete(true)}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ interface User {
|
|||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
/** True until the first /users/me answer when nothing is persisted. */
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
@@ -24,7 +25,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
setUser: (user) => set({ user, isAuthenticated: !!user, isLoading: false }),
|
setUser: (user) => set({ user, isAuthenticated: !!user, isLoading: false }),
|
||||||
setLoading: (isLoading) => set({ isLoading }),
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
logout: () => set({ user: null, isAuthenticated: false }),
|
logout: () => set({ user: null, isAuthenticated: false, isLoading: false }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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
@@ -9,62 +9,96 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
// Never reload the page under the reader: the app shows an update toast.
|
||||||
includeAssets: ['favicon.svg', 'robots.txt', 'apple-touch-icon.png'],
|
registerType: 'prompt',
|
||||||
|
includeAssets: ['favicon.svg', 'theme-init.js'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'FlowReader',
|
name: 'FlowReader',
|
||||||
short_name: 'FlowReader',
|
short_name: 'FlowReader',
|
||||||
description: 'Lecteur RSS minimaliste et rapide',
|
description: 'Lecteur RSS minimaliste et rapide',
|
||||||
theme_color: '#166534',
|
lang: 'fr',
|
||||||
background_color: '#166534',
|
theme_color: '#15643A',
|
||||||
|
background_color: '#F7F4EC',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: 'favicon.svg',
|
src: 'favicon.svg',
|
||||||
sizes: 'any',
|
sizes: 'any',
|
||||||
type: 'image/svg+xml',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any maskable'
|
purpose: 'any',
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
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: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/images\.unsplash\.com\/.*/i,
|
// Opened articles stay readable offline.
|
||||||
handler: 'CacheFirst',
|
urlPattern: ({ url, request }) =>
|
||||||
|
request.method === 'GET' && /^\/api\/v1\/articles\/[0-9a-f-]{36}$/.test(url.pathname),
|
||||||
|
handler: 'NetworkFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'unsplash-images',
|
cacheName: 'api-articles',
|
||||||
expiration: {
|
networkTimeoutSeconds: 4,
|
||||||
maxEntries: 50,
|
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 },
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 Days
|
cacheableResponse: { statuses: [200] },
|
||||||
},
|
|
||||||
cacheableResponse: {
|
|
||||||
statuses: [0, 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',
|
handler: 'NetworkFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'api-cache',
|
cacheName: 'api-lists',
|
||||||
expiration: {
|
networkTimeoutSeconds: 4,
|
||||||
maxEntries: 100,
|
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 },
|
||||||
maxAgeSeconds: 60 * 60 * 24, // 24 Hours
|
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: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: process.env.VITE_API_TARGET ?? 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user