diff --git a/web/src/App.tsx b/web/src/App.tsx index 36c3a6f..5d5e254 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense, useEffect } from 'react'; +import { Suspense, useEffect } from 'react'; +import { lazyNamed } from './lib/lazy'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useAuthStore, clearUserData } from './stores/authStore'; @@ -8,9 +9,9 @@ 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 LoginPage = lazyNamed(() => import('./pages/LoginPage'), 'LoginPage'); +const RegisterPage = lazyNamed(() => import('./pages/RegisterPage'), 'RegisterPage'); +const RootLayout = lazyNamed(() => import('./layouts/RootLayout'), 'RootLayout'); const queryClient = new QueryClient({ defaultOptions: { diff --git a/web/src/components/ArticleCard.tsx b/web/src/components/ArticleCard.tsx index 6d285ee..2c3ffa9 100644 --- a/web/src/components/ArticleCard.tsx +++ b/web/src/components/ArticleCard.tsx @@ -91,7 +91,7 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo {!article.is_read && ( diff --git a/web/src/components/SearchBox.tsx b/web/src/components/SearchBox.tsx deleted file mode 100644 index cde3b03..0000000 --- a/web/src/components/SearchBox.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { forwardRef, useEffect, useState } from 'react'; - -interface SearchBoxProps { - onSearch: (query: string) => void; -} - -/** - * Owns the raw input value so typing only re-renders this box; the list - * receives the debounced query. - */ -export const SearchBox = forwardRef(function SearchBox({ onSearch }, ref) { - const [value, setValue] = useState(''); - - useEffect(() => { - const t = window.setTimeout(() => onSearch(value.trim()), 350); - return () => window.clearTimeout(t); - }, [value, onSearch]); - - return ( -
- - - - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setValue(''); - e.currentTarget.blur(); - } - }} - className="input-field !py-2.5 pl-10 pr-4 !rounded-full w-full sm:w-56 sm:focus:w-72 transition-[width] text-sm" - /> -
- ); -}); diff --git a/web/src/components/ShortcutsHelp.tsx b/web/src/components/ShortcutsHelp.tsx index 80614d7..3cda949 100644 --- a/web/src/components/ShortcutsHelp.tsx +++ b/web/src/components/ShortcutsHelp.tsx @@ -9,7 +9,6 @@ const GROUPS: { title: string; keys: [string, string][] }[] = [ ['m', 'Marquer lu / non lu'], ['s · f', 'Favori'], ['v', 'Ouvrir l’original'], - ['/', 'Rechercher'], ['u', 'Basculer Non lus / Tous'], ['r', 'Actualiser les flux'], ], @@ -18,9 +17,9 @@ const GROUPS: { title: string; keys: [string, string][] }[] = [ title: 'Lecture', keys: [ ['Espace', 'Défiler, puis article suivant'], - ['j · → / k · ←', 'Suivant / précédent'], + ['j · → / k · ←', 'Article suivant / précédent'], ['+ / -', 'Taille du texte'], - ['Échap', 'Fermer'], + ['Échap', 'Fermer l’article'], ], }, ]; @@ -51,11 +50,11 @@ export function ShortcutsHelp({ onClose }: { onClose: () => void }) { className="surface-card w-full max-w-lg p-7 outline-none animate-rise" onClick={(e) => e.stopPropagation()} > -

Raccourcis clavier

+

Raccourcis clavier

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

{g.title}

+

{g.title}

{g.keys.map(([k, label]) => (
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 9262d26..56b5e8c 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -85,7 +85,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
@@ -141,7 +141,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod : 'bg-nature/5 text-nature border border-nature/20 hover:bg-nature hover:text-on-nature' }`} > - + @@ -150,7 +150,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
-

Mes flux

+

Flux

{feeds && feeds.length > 0 ? feeds.map((feed) => (
@@ -207,7 +207,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod

{user?.email ?? 'Compte'}

-

+

{user?.is_admin ? 'Administrateur' : 'Lecteur'}

diff --git a/web/src/components/reader/Reader.tsx b/web/src/components/reader/Reader.tsx index 381bcde..4102212 100644 --- a/web/src/components/reader/Reader.tsx +++ b/web/src/components/reader/Reader.tsx @@ -32,9 +32,16 @@ function isTypingTarget(t: EventTarget | null) { return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); } +const Chevron = ({ dir }: { dir: 'left' | 'right' }) => ( + +); + /** - * 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. + * Article opened as a large card above the (dimmed) grid. + * Close: ✕, Escape or a click beside the card. Previous / next: side arrows + * on desktop, bottom bar on mobile, j/k or ←/→ on the keyboard. */ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, onPrev }: ReaderProps) { const qc = useQueryClient(); @@ -45,7 +52,7 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o const font = usePrefs((s) => s.font); const setFontSize = usePrefs((s) => s.setFontSize); - const scrollRef = useRef(null); + const overlayRef = useRef(null); const progressRef = useRef(null); const titleRef = useRef(null); const [settingsOpen, setSettingsOpen] = useState(false); @@ -71,16 +78,19 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o 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); + }, 500); return () => window.clearTimeout(t); }, [next, qc]); - // Modal behaviour: make the app inert, move focus in, restore it on close. + // Modal behaviour: the app behind is inert and doesn't scroll; focus + // moves to the title and returns to the card that opened the article. useLayoutEffect(() => { const root = document.getElementById('root'); const previous = document.activeElement as HTMLElement | null; root?.setAttribute('inert', ''); document.body.style.overflow = 'hidden'; + overlayRef.current?.scrollTo({ top: 0 }); + titleRef.current?.focus({ preventScroll: true }); return () => { root?.removeAttribute('inert'); document.body.style.overflow = ''; @@ -88,26 +98,16 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o }; }, []); - // 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; + const el = overlayRef.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))); - } + if (progressRef.current) progressRef.current.style.transform = `scaleX(${ratio})`; }; const onScroll = () => { if (!frame) frame = requestAnimationFrame(update); @@ -124,34 +124,29 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o 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; + const el = overlayRef.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 '+': @@ -162,7 +157,6 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o 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) { @@ -180,17 +174,17 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o return () => window.removeEventListener('keydown', onKey); }, [onClose, onNext, onPrev, hasPrev, toggleFavorite, toggleRead, listArticle, openOriginal, fontSize, setFontSize]); - // Touch swipe between articles; ignored over horizontally scrollable content. + // Optional touch shortcut on phones; the buttons below do the same. const swipe = useSwipeable({ onSwipedLeft: (ev) => { - if (!(ev.event.target as Element).closest('pre,table,[data-noswipe]') && onNext) onNext(); + if (!(ev.event.target as Element).closest('pre,table') && onNext) onNext(); }, onSwipedRight: (ev) => { - if (!(ev.event.target as Element).closest('pre,table,[data-noswipe]') && hasPrev && onPrev) onPrev(); + if (!(ev.event.target as Element).closest('pre,table') && hasPrev && onPrev) onPrev(); }, trackMouse: false, - delta: 80, - swipeDuration: 500, + delta: 90, + swipeDuration: 450, preventScrollOnSwipe: false, }); @@ -213,170 +207,156 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o '--reader-font': FONTS[font], letterSpacing: font === 'legible' ? '0.02em' : undefined, } as React.CSSProperties; - const measure = MEASURES[width]; + + const date = listArticle.published_at || listArticle.created_at; const body = (
{ + // Click beside the card closes it. + if (e.target === e.currentTarget) onClose(); + }} > - {/* Progress */} -
-
-
+ {/* Side arrows (desktop) */} + {hasPrev && onPrev && ( + + )} + {onNext && ( + + )} - {/* Toolbar */} -
-
- -
- - -
- - - - - - -
+
+ {/* Card header: source on the left, actions and close on the right */} +
+
+
+

{listArticle.feed_title || 'Article'}

+

+ , {readingTimeLabel(listArticle.reading_time)} de lecture +

+
- {settingsOpen && setSettingsOpen(false)} />} -
- {listArticle.url && ( - - - )} -
-
- -
- {listArticle.image_url && ( -
- (e.currentTarget.style.display = 'none')} - /> + + +
+ + {settingsOpen && setSettingsOpen(false)} />} +
+
+
+ +
+ + {listArticle.image_url && ( + (e.currentTarget.style.display = 'none')} + /> )} -
+
-
- {listArticle.feed_title || 'Journal'} - - - {readingTimeLabel(listArticle.reading_time)} de lecture -
-

{listArticle.title}

- {listArticle.author &&

Par {listArticle.author}

} + {listArticle.author &&

Par {listArticle.author}

} - {/* AI digest */} -
- {article.ai_summary ? ( - - ) : ( - <> - - {summaryError &&

{summaryError}

} - - )} -
+ {article.ai_summary ? ( + + ) : ( +
+ + {summaryError &&

{summaryError}

} +
+ )}
{detail ? ( html ? ( -
+
) : ( -

- Ce flux ne publie qu’un titre. {listArticle.url && 'Ouvrez l’article original pour le lire en entier.'} -

+

Ce flux ne publie que le titre de l’article.

) ) : isError ? ( -
+

Impossible de charger l’article.{' '} -

+

) : (
{[92, 100, 85, 97, 60].map((w, i) => ( @@ -386,32 +366,46 @@ export function Reader({ article: listArticle, next, hasPrev, onClose, onNext, o )} {listArticle.url && ( -

- - Lire sur le site d’origine - -

- )} - - {/* Up next */} - {next && onNext && ( - + Lire sur le site d’origine + + )}
+ + {/* Bottom navigation: always visible on mobile, "up next" on every size */} +
); diff --git a/web/src/index.css b/web/src/index.css index 21842ca..ac079d8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -189,21 +189,21 @@ @layer components { /* Eyebrow / kicker label */ .eyebrow { - @apply text-[11px] uppercase tracking-[0.3em] font-bold text-nature; + @apply text-sm font-semibold text-paper-muted; } /* Buttons */ .btn-primary { - @apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-bold - min-h-11 px-6 py-2.5 rounded-full text-xs uppercase tracking-[0.16em] + @apply inline-flex items-center justify-center gap-2 bg-nature text-on-nature font-semibold + min-h-11 px-5 py-2.5 rounded-full text-sm border border-transparent transition-all duration-200 hover:bg-nature-light hover:shadow-[0_8px_24px_-8px_rgba(21,100,58,0.5)] active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none; } .btn-secondary { - @apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-bold - min-h-11 px-5 py-2.5 rounded-full text-xs uppercase tracking-[0.16em] + @apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-semibold + min-h-11 px-5 py-2.5 rounded-full text-sm border border-nature/35 transition-all duration-200 hover:bg-nature hover:text-on-nature hover:border-nature active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none; @@ -246,7 +246,7 @@ /* Chip / badge */ .chip { @apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full - text-[11px] font-bold uppercase tracking-[0.14em] + text-xs font-semibold bg-nature/10 text-nature; } diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index b22e036..8f42520 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -1,11 +1,12 @@ -import { lazy, Suspense, useState } from 'react'; +import { Suspense, useState } from 'react'; +import { lazyNamed } from '../lib/lazy'; import { Sidebar } from '../components/Sidebar'; import { MobileTopBar } from '../components/MobileTopBar'; import { DashboardPage } from '../pages/DashboardPage'; 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 }))); +const FocusPage = lazyNamed(() => import('../pages/FocusPage'), 'FocusPage'); export function RootLayout() { const [selectedFeedId, setSelectedFeedId] = useState(null); @@ -35,7 +36,7 @@ export function RootLayout() { /> )} {/* Kept mounted in focus mode so the list keeps its scroll position. */} - setIsFocusMode(true)} hidden={isFocusMode} /> +
{isFocusMode && ( diff --git a/web/src/lib/lazy.ts b/web/src/lib/lazy.ts new file mode 100644 index 0000000..1cd2a3d --- /dev/null +++ b/web/src/lib/lazy.ts @@ -0,0 +1,23 @@ +import { lazy, type ComponentType } from 'react'; + +/** + * React.lazy for a named export. After a deploy, chunks of the previous build + * no longer exist on the server: reload once to pick up the new version + * instead of showing a blank screen. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function lazyNamed>(load: () => Promise>, name: string) { + return lazy(async () => { + try { + const mod = await load(); + sessionStorage.removeItem('chunk-reload'); + return { default: mod[name] as T }; + } catch (err) { + if (!sessionStorage.getItem('chunk-reload')) { + sessionStorage.setItem('chunk-reload', '1'); + window.location.reload(); + } + throw err; + } + }); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index d93e6f7..ab18afb 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -14,13 +14,25 @@ import './index.css' import App from './App.tsx' import { useUpdate } from './stores/updateStore' -// Prompt-based updates: a new version never reloads the page mid-read. -const updateSW = registerSW({ +const reload = () => window.location.reload() + +registerSW({ + immediate: true, onNeedRefresh() { - useUpdate.getState().setAvailable(() => updateSW(true)) + useUpdate.getState().setAvailable(reload) }, }) +// The service worker activates new versions right away; when it takes over a +// page that was already controlled, offer a reload instead of forcing one +// under the reader. +if ('serviceWorker' in navigator) { + const hadController = !!navigator.serviceWorker.controller + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (hadController) useUpdate.getState().setAvailable(reload) + }) +} + createRoot(document.getElementById('root')!).render( diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index b3865ed..053945e 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -5,7 +5,6 @@ import { articlesApi, cursorAfter, type Article } from '../api/articles'; import { feedsApi } from '../api/feeds'; import { ArticleCard } from '../components/ArticleCard'; import { Reader } from '../components/reader/Reader'; -import { SearchBox } from '../components/SearchBox'; import { ShortcutsHelp } from '../components/ShortcutsHelp'; import { findCachedArticle, useArticleActions } from '../hooks/useArticleActions'; import { useLive } from '../hooks/useWebsocket'; @@ -16,7 +15,6 @@ const PAGE_SIZE = 30; interface DashboardPageProps { selectedFeedId: string | null; - onEnterFocus: () => void; /** Hidden (kept mounted) while focus mode is shown, to preserve scroll. */ hidden?: boolean; } @@ -26,14 +24,12 @@ function isTypingTarget(t: EventTarget | null) { return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); } -export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: DashboardPageProps) { +export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) { const qc = useQueryClient(); const [params, setParams] = useSearchParams(); - const [query, setQuery] = useState(''); const [showHelp, setShowHelp] = useState(false); const mainRef = useRef(null); const loadMoreRef = useRef(null); - const searchRef = useRef(null); const [showScrollTop, setShowScrollTop] = useState(false); const unreadPref = usePrefs((s) => s.unreadOnly); @@ -44,27 +40,21 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar const favorites = selectedFeedId === 'favorites'; const feedId = favorites ? null : selectedFeedId; - const searching = query.length > 0; - const unreadOnly = unreadPref && !favorites && !searching; + const unreadOnly = unreadPref && !favorites; const listKey = useMemo( - () => ['articles', { feedId, favorites, unread: unreadOnly, q: query }] as const, - [feedId, favorites, unreadOnly, query], + () => ['articles', { feedId, favorites, unread: unreadOnly }] as const, + [feedId, favorites, unreadOnly], ); const { data, isPending, isError, refetch, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage, isPlaceholderData } = useInfiniteQuery({ queryKey: listKey, queryFn: ({ pageParam }) => - searching - ? articlesApi.search(query, PAGE_SIZE, Number(pageParam) || 0) - : articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }), + articlesApi.list({ limit: PAGE_SIZE, cursor: pageParam || undefined, unread: unreadOnly, favorites, feedId }), initialPageParam: '' as string, - getNextPageParam: (last, all) => { - if (last.length < PAGE_SIZE) return undefined; - return searching ? String(all.length * PAGE_SIZE) : cursorAfter(last[last.length - 1]); - }, - // Keep showing the previous list while a new filter/search loads. + getNextPageParam: (last) => (last.length < PAGE_SIZE ? undefined : cursorAfter(last[last.length - 1])), + // Keep showing the previous list while another feed or filter loads. placeholderData: keepPreviousData, }); @@ -214,9 +204,6 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar case 'v': if (current?.url) window.open(current.url, '_blank', 'noopener,noreferrer'); break; - case '/': - searchRef.current?.focus(); - break; case 'u': setUnreadOnly(!unreadPref); break; @@ -235,108 +222,73 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar return () => window.removeEventListener('keydown', onKey); }, [hidden, openId, articles, hasNextPage, fetchNextPage, openReader, toggleRead, toggleFavorite, setUnreadOnly, unreadPref, refreshMutation]); - const heading = searching - ? `« ${query} »` - : favorites - ? 'Mes favoris' - : feedTitle ?? 'La Une'; + const heading = favorites ? 'Favoris' : (feedTitle ?? 'Tous les articles'); return (
- {/* Masthead */} -
-
-
-

{searching ? 'Recherche' : 'Édition du jour'}

-

- {heading} -

-

- {new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })} - {!searching && !favorites && ( - · {unreadTotal} non lu{unreadTotal > 1 ? 's' : ''} - )} +

+
+

{heading}

+ {!favorites && ( +

+ {unreadTotal === 0 + ? 'Tout est lu' + : `${unreadTotal} article${unreadTotal > 1 ? 's' : ''} non lu${unreadTotal > 1 ? 's' : ''}`}

-
+ )} +
-
-
- +
+ {!favorites && ( +
+ +
- {!favorites && !searching && ( -
- - -
- )} + )} + + {!favorites && unreadTotal > 0 && ( - {!favorites && unreadTotal > 0 && ( - - )} - -
+ )}
- {/* New articles announcement (never inserted under the reader's eyes) */} - {newCount > 0 && !searching && ( -
-
)} - {/* Focus CTA */} - {!feedId && !favorites && !searching && unreadTotal > 0 && ( - - )} - {/* Grid */} {isPending ? ( -
+
{Array.from({ length: 6 }, (_, i) => (
@@ -350,7 +302,7 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar
) : isError && articles.length === 0 ? (
-

+

{navigator.onLine ? 'Impossible de charger vos articles.' : 'Vous êtes hors ligne.'}

@@ -358,7 +310,7 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar ) : articles.length > 0 ? ( <>
{articles.map((article, i) => (
) : null} @@ -386,25 +338,21 @@ export function DashboardPage({ selectedFeedId, onEnterFocus, hidden }: Dashboar ) : (
-

- {searching - ? `Aucun article pour « ${query} ».` - : favorites - ? 'Aucun favori pour le moment.' - : unreadOnly && (feeds?.length ?? 0) > 0 - ? 'Vous êtes à jour 🌿' - : 'Votre bibliothèque est vide.'} +

+ {favorites + ? 'Aucun favori' + : unreadOnly && (feeds?.length ?? 0) > 0 + ? 'Tout est lu' + : 'Aucun article'}

- {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 +.'} + {favorites + ? 'Touchez l’étoile d’un article pour le retrouver ici.' + : unreadOnly && (feeds?.length ?? 0) > 0 + ? 'Les nouveaux articles apparaîtront ici.' + : 'Ajoutez un flux avec le bouton +.'}

- {unreadOnly && !searching && (feeds?.length ?? 0) > 0 && ( + {unreadOnly && (feeds?.length ?? 0) > 0 && ( )}
diff --git a/web/vite.config.ts b/web/vite.config.ts index 3394159..01350b1 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -35,6 +35,12 @@ export default defineConfig({ globPatterns: ['**/*.{js,css,html,svg}', '**/*-latin-wght-*.woff2', '**/*-latin-[47]00-*.woff2'], navigateFallbackDenylist: [/^\/api\//, /^\/health/], cleanupOutdatedCaches: true, + // Activate new versions immediately. A waiting worker would keep + // serving a stale app shell to clients that never send SKIP_WAITING + // (e.g. tabs still running a previous build), so the old UI would talk + // to the new API. The app shows a reload toast instead of reloading. + skipWaiting: true, + clientsClaim: true, runtimeCaching: [ { // Opened articles stay readable offline.