From 10fdfdeccb5765e7059863fb44703c7a6334d945 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Thu, 11 Jun 2026 13:21:23 +0200 Subject: [PATCH] =?UTF-8?q?refonte=20compl=C3=A8te=20du=20WebUI=20(=C3=89d?= =?UTF-8?q?itorial=20Botanique)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte de toutes les vues en conservant l'identité magazine/vert nature, avec un système de design cohérent et accessible. - index.css : tokens sémantiques, échelle radius/ombres, classes réutilisables (btn-*, chip, surface-card, magazine-content), :focus-visible, prefers-reduced-motion, grain papier affiné - nouveaux composants AuthShell (auth split-screen) et MobileTopBar (navigation mobile, auparavant absente) - framer-motion sur toutes les vues ; AddFeedModal expose l'import OPML ; Sidebar ajoute déconnexion + identité utilisateur - corrections : text-gold cassé -> nature, arithmétique Date -> getTime(), suppression de App.css mort, ProtectedRoute en Tailwind, snapshot du deck Focus conforme aux règles React Frontend : npm run lint + build OK. Co-Authored-By: Claude Opus 4.8 --- web/src/App.css | 552 ------------------- web/src/components/AddFeedModal.tsx | 154 ++++-- web/src/components/ArticleCard.tsx | 198 +++---- web/src/components/AuthShell.tsx | 78 +++ web/src/components/MobileReaderView.tsx | 161 +++--- web/src/components/MobileTopBar.tsx | 65 +++ web/src/components/ProtectedRoute.tsx | 6 +- web/src/components/ReaderView.tsx | 189 ++++--- web/src/components/Sidebar.tsx | 313 ++++++----- web/src/components/ThemeToggle.tsx | 56 +- web/src/components/focus/FocusCard.tsx | 65 +-- web/src/components/focus/FocusCardStack.tsx | 254 +++------ web/src/components/focus/FocusEmptyState.tsx | 26 +- web/src/index.css | 363 +++++++----- web/src/layouts/RootLayout.tsx | 14 +- web/src/pages/DashboardPage.tsx | 378 +++++-------- web/src/pages/FocusPage.tsx | 104 ++-- web/src/pages/LoginPage.tsx | 129 +++-- web/src/pages/RegisterPage.tsx | 153 +++-- 19 files changed, 1336 insertions(+), 1922 deletions(-) delete mode 100644 web/src/App.css create mode 100644 web/src/components/AuthShell.tsx create mode 100644 web/src/components/MobileTopBar.tsx diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index 14ac96f..0000000 --- a/web/src/App.css +++ /dev/null @@ -1,552 +0,0 @@ -:root { - --primary: #6366f1; - --primary-hover: #4f46e5; - --background: #0f172a; - --surface: #1e293b; - --surface-hover: #334155; - --text: #f8fafc; - --text-muted: #94a3b8; - --error: #ef4444; - --success: #22c55e; - --border: #334155; - --radius: 12px; -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--background); - color: var(--text); - min-height: 100vh; -} - -/* Login/Register Container */ -.login-container { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - background: linear-gradient(135deg, var(--background) 0%, #1a1a2e 100%); -} - -.login-card { - background: var(--surface); - border-radius: var(--radius); - padding: 2.5rem; - width: 100%; - max-width: 400px; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); -} - -.login-card h1 { - font-size: 2rem; - font-weight: 700; - text-align: center; - background: linear-gradient(135deg, var(--primary) 0%, #a855f7 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin-bottom: 0.5rem; -} - -.login-card .subtitle { - text-align: center; - color: var(--text-muted); - margin-bottom: 2rem; -} - -/* Form Styles */ -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-muted); - margin-bottom: 0.5rem; -} - -.form-group input { - width: 100%; - padding: 0.875rem 1rem; - background: var(--background); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - font-size: 1rem; - transition: all 0.2s ease; -} - -.form-group input:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); -} - -.form-group input::placeholder { - color: var(--text-muted); -} - -button[type="submit"] { - width: 100%; - padding: 0.875rem; - background: var(--primary); - border: none; - border-radius: 8px; - color: white; - font-size: 1rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - margin-top: 0.5rem; -} - -button[type="submit"]:hover:not(:disabled) { - background: var(--primary-hover); - transform: translateY(-1px); -} - -button[type="submit"]:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.error-message { - background: rgba(239, 68, 68, 0.1); - border: 1px solid var(--error); - color: var(--error); - padding: 0.75rem; - border-radius: 8px; - margin-bottom: 1rem; - font-size: 0.875rem; -} - -.register-link { - text-align: center; - margin-top: 1.5rem; - color: var(--text-muted); - font-size: 0.875rem; -} - -.register-link a { - color: var(--primary); - text-decoration: none; - font-weight: 500; -} - -.register-link a:hover { - text-decoration: underline; -} - -/* Dashboard Layout */ -.dashboard-layout { - display: flex; - height: 100vh; - overflow: hidden; -} - -.sidebar { - width: 280px; - background: var(--surface); - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; -} - -.sidebar-nav { - flex: 1; - padding: 1rem; - overflow-y: auto; -} - -.nav-item { - width: 100%; - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - background: transparent; - border: none; - border-radius: 8px; - color: var(--text-muted); - font-size: 0.9375rem; - cursor: pointer; - transition: all 0.2s ease; - margin-bottom: 0.25rem; - text-align: left; -} - -.nav-item:hover { - background: var(--surface-hover); - color: var(--text); -} - -.nav-item.active { - background: rgba(99, 102, 241, 0.1); - color: var(--primary); - font-weight: 500; -} - -.nav-item .icon { - font-size: 1.125rem; -} - -.nav-item .label { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.nav-item .badge { - background: var(--primary); - color: white; - font-size: 0.75rem; - padding: 0.125rem 0.5rem; - border-radius: 10px; - font-weight: 600; -} - -.sidebar-section { - margin-top: 2rem; -} - -.sidebar-section h3 { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - padding: 0 1rem; - margin-bottom: 0.75rem; -} - -.sidebar-footer { - padding: 1rem; - border-top: 1px solid var(--border); -} - -.add-feed-btn { - width: 100%; - padding: 0.75rem; - background: transparent; - border: 1px dashed var(--border); - border-radius: 8px; - color: var(--text-muted); - cursor: pointer; - transition: all 0.2s ease; -} - -.add-feed-btn:hover { - border-color: var(--primary); - color: var(--primary); -} - -.dashboard-header { - padding: 1rem 1.5rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--border); - background: var(--surface); -} - -.header-actions { - display: flex; - align-items: center; - gap: 1.5rem; -} - -.mark-all-read-btn { - background: transparent; - border: 1px solid var(--primary); - color: var(--primary); - padding: 0.5rem 1rem; - border-radius: 6px; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: all 0.2s ease; -} - -.mark-all-read-btn:hover:not(:disabled) { - background: var(--primary); - color: white; -} - -.mark-all-read-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.user-info { - display: flex; - align-items: center; - gap: 1rem; -} - -.articles-main { - flex: 1; - overflow-y: auto; - padding: 2rem; -} - -.articles-grid { - max-width: 800px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -/* Article Card */ -.article-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1.5rem; - cursor: pointer; - transition: all 0.2s ease; - position: relative; -} - -.article-card:hover { - border-color: var(--primary); - transform: translateY(-2px); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3); -} - -.article-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; - font-size: 0.75rem; - color: var(--text-muted); -} - -.feed-title { - font-weight: 600; - color: var(--primary); -} - -.article-title { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.75rem; - line-height: 1.4; -} - -.article-excerpt { - color: var(--text-muted); - font-size: 0.9375rem; - line-height: 1.6; - margin-bottom: 1.25rem; -} - -.article-footer { - display: flex; - gap: 1rem; -} - -.action-btn { - background: transparent; - border: 1px solid var(--border); - color: var(--text-muted); - padding: 0.375rem 0.75rem; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - font-size: 1rem; -} - -.action-btn:hover { - background: var(--surface-hover); - color: var(--text); -} - -.action-btn.active { - color: #fbbf24; - border-color: #fbbf24; -} - -/* Reader View Overlay */ -.reader-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.85); - display: flex; - justify-content: flex-end; - z-index: 1000; - backdrop-filter: blur(4px); -} - -.reader-container { - width: 100%; - max-width: 800px; - background: var(--background); - height: 100%; - display: flex; - flex-direction: column; - animation: slideIn 0.3s ease-out; - box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5); -} - -@keyframes slideIn { - from { - transform: translateX(100%); - } - - to { - transform: translateX(0); - } -} - -.reader-header { - padding: 1rem 2rem; - border-bottom: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; - background: var(--surface); -} - -.close-btn { - background: transparent; - border: none; - color: var(--text); - font-size: 1rem; - cursor: pointer; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.reader-actions { - display: flex; - gap: 1rem; -} - -.reader-content { - flex: 1; - overflow-y: auto; - padding: 3rem 4rem; - line-height: 1.6; -} - -.reader-content .meta { - display: flex; - gap: 1rem; - font-size: 0.875rem; - color: var(--text-muted); - margin-bottom: 1.5rem; -} - -.reader-content .title { - font-size: 2.5rem; - font-weight: 800; - margin-bottom: 1rem; - line-height: 1.2; -} - -.reader-content .author { - color: var(--text-muted); - font-style: italic; - margin-bottom: 2rem; -} - -.content-body { - font-size: 1.125rem; - color: #e2e8f0; -} - -.content-body p { - margin-bottom: 1.5rem; -} - -.content-body img { - max-width: 100%; - height: auto; - border-radius: 8px; - margin: 1.5rem 0; -} - -.content-body a { - color: var(--primary); - text-decoration: underline; -} - -.content-body h1, -.content-body h2, -.content-body h3 { - margin-top: 2rem; - margin-bottom: 1rem; -} - -/* Loading/Empty States */ -.loading-state { - display: flex; - justify-content: center; - padding: 4rem; -} - -/* Mobile Adjustments */ -@media (max-width: 768px) { - .sidebar { - display: none; - } - - .reader-content { - padding: 2rem 1.5rem; - } - - .reader-content .title { - font-size: 1.75rem; - } -} - -/* Loading */ -.loading-screen { - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; -} - -.spinner { - width: 40px; - height: 40px; - border: 3px solid var(--border); - border-top-color: var(--primary); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* Mobile First */ -@media (max-width: 480px) { - .login-card { - padding: 1.5rem; - } - - .login-card h1 { - font-size: 1.5rem; - } -} \ No newline at end of file diff --git a/web/src/components/AddFeedModal.tsx b/web/src/components/AddFeedModal.tsx index 75659f1..8da34c1 100644 --- a/web/src/components/AddFeedModal.tsx +++ b/web/src/components/AddFeedModal.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AnimatePresence, motion } from 'framer-motion'; import { feedsApi } from '../api/feeds'; interface AddFeedModalProps { @@ -9,10 +10,11 @@ interface AddFeedModalProps { export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) { const [url, setUrl] = useState(''); + const fileInputRef = useRef(null); const queryClient = useQueryClient(); const addFeedMutation = useMutation({ - mutationFn: (url: string) => feedsApi.add({ url }), + mutationFn: (u: string) => feedsApi.add({ url: u }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['feeds'] }); setUrl(''); @@ -20,68 +22,112 @@ export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) { }, }); - if (!isOpen) return null; + const importMutation = useMutation({ + mutationFn: (file: File) => feedsApi.importOPML(file), + onSuccess: (res) => { + queryClient.invalidateQueries({ queryKey: ['feeds'] }); + queryClient.invalidateQueries({ queryKey: ['articles'] }); + window.alert(`${res.imported} flux importé(s), ${res.skipped} ignoré(s).`); + onClose(); + }, + }); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, onClose]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); addFeedMutation.mutate(url); }; + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) importMutation.mutate(file); + }; + return ( -
-
e.stopPropagation()} - > -
-

Nouveau Flux

- -
+ + {isOpen && ( + + e.stopPropagation()} + > +
+

Nouveau flux

+ +
-

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

+

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

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

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

- )} + {addFeedMutation.isError && ( +

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

+ )} -
- - -
-
-
-
+
+ + +
+ + + {/* OPML import */} +
+ + +
+ + + )} + ); } diff --git a/web/src/components/ArticleCard.tsx b/web/src/components/ArticleCard.tsx index 23d9ef8..0f06dc0 100644 --- a/web/src/components/ArticleCard.tsx +++ b/web/src/components/ArticleCard.tsx @@ -9,6 +9,14 @@ interface ArticleCardProps { onToggleFavorite: (id: string) => void; } +const FALLBACK_IMAGE = + 'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800'; + +function plainExcerpt(html: string, max = 130): string { + const text = html.replace(/<[^>]*>?/gm, '').trim(); + return text.length > max ? `${text.slice(0, max)}…` : text; +} + export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) { const [swipeOffset, setSwipeOffset] = useState(0); const [isSwiping, setIsSwiping] = useState(false); @@ -16,23 +24,17 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite } const handlers = useSwipeable({ onSwiping: (event) => { - const offset = event.deltaX; - // Limit swipe to 80px - if (Math.abs(offset) < 100) { - setSwipeOffset(offset); + if (Math.abs(event.deltaX) < 100) { + setSwipeOffset(event.deltaX); setIsSwiping(true); } }, onSwipedRight: () => { - if (swipeOffset > 50) { - onToggleRead(article.id, !article.is_read); - } + if (swipeOffset > 50) onToggleRead(article.id, !article.is_read); resetSwipe(); }, onSwipedLeft: () => { - if (swipeOffset < -50) { - onToggleFavorite(article.id); - } + if (swipeOffset < -50) onToggleFavorite(article.id); resetSwipe(); }, onSwiped: () => resetSwipe(), @@ -49,156 +51,120 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite } const handleSummarize = async (e: React.MouseEvent) => { e.stopPropagation(); if (article.ai_summary || isSummarizing) return; - setIsSummarizing(true); try { await articlesApi.summarize(article.id); - // The article will be updated via WebSocket broadcast } catch (error) { console.error('Failed to summarize:', error); - alert('Erreur lors de la génération du résumé. Vérifiez votre clé API OpenRouter.'); } finally { setIsSummarizing(false); } }; return ( -
- {/* Action Layers behind the card */} +
+ {/* Swipe action hints behind the card */}
- {/* Left side swipe (Mark as Read) */} -
10 ? 'opacity-100' : 'opacity-0'}`}> -
- - - -
- - {article.is_read ? 'Marquer non lu' : 'Marquer lu'} - -
- - {/* Right side swipe (Favorite) */} -
- Favori -
- - - -
-
+ 10 ? 'opacity-100' : 'opacity-0'}`} + > + {article.is_read ? 'Marquer non lu' : 'Marquer lu'} + + + Favori +
- {/* Main Interactive Card */}
!isSwiping && Math.abs(swipeOffset) < 5 && onClick(article)} + className={`group relative z-10 cursor-pointer flex flex-col overflow-hidden rounded-2xl + bg-carbon-light border transition-colors + ${article.is_read ? 'border-paper-muted/10' : 'border-nature/20'} + hover:border-nature/40`} style={{ transform: `translateX(${swipeOffset}px)`, - boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.1)' + transition: isSwiping ? 'none' : 'transform 0.45s cubic-bezier(0.22,1,0.36,1)', + boxShadow: 'var(--shadow-soft)', }} - onClick={() => !isSwiping && Math.abs(swipeOffset) < 5 && onClick(article)} > - - {/* Unread Ribbon - Polished Wrapping Effect */} - {!article.is_read && ( -
- {/* The Ribbon Itself */} -
- Nouveau -
- - {/* Darker Folds for "Wrapping" Effect (Simulated behind the card) */} -
-
-
- )} - {!article.is_read && ( - /* External fold bits to cover the corner if needed for perfection */ - <> -
-
- - )} - - {/* Image Placeholder with Gradient Overlay */} -
- - - {/* Visual placeholder or real image */} + {/* Cover */} +
+ style={{ backgroundImage: `url(${article.image_url || FALLBACK_IMAGE})` }} + /> +
- {/* Meta on top of image */} -
- - {article.feed_title || 'Journal'} - -
+ + {article.feed_title || 'Journal'} + + + {!article.is_read && ( + + )}
- {/* Content */} -
-

+

{article.title}

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

- {article.summary.replace(/<[^>]*>?/gm, '').substring(0, 120)}... + ✨ Résumé disponible + ) : article.summary ? ( +

+ {plainExcerpt(article.summary)}

- )} + ) : null} -
-
+
+
+ +
- + +
diff --git a/web/src/components/AuthShell.tsx b/web/src/components/AuthShell.tsx new file mode 100644 index 0000000..3e7a2c8 --- /dev/null +++ b/web/src/components/AuthShell.tsx @@ -0,0 +1,78 @@ +import { motion } from 'framer-motion'; + +interface AuthShellProps { + eyebrow: string; + title: string; + subtitle: string; + children: React.ReactNode; +} + +/** + * Split-screen editorial layout shared by Login & Register. + * Left: brand panel (hidden on mobile). Right: the form card. + */ +export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps) { + return ( +
+ {/* Brand panel */} + + + {/* Form side */} +
+ +
+ FlowReader +
+ +
+
+

{eyebrow}

+

{title}

+

{subtitle}

+
+ {children} +
+
+
+
+ ); +} diff --git a/web/src/components/MobileReaderView.tsx b/web/src/components/MobileReaderView.tsx index a0ebd28..58504eb 100644 --- a/web/src/components/MobileReaderView.tsx +++ b/web/src/components/MobileReaderView.tsx @@ -1,6 +1,8 @@ -import type { Article } from '../api/articles'; +import { useState } from 'react'; import { useSwipeable } from 'react-swipeable'; +import { motion } from 'framer-motion'; import { ShareButton } from './ShareButton'; +import { type Article, articlesApi } from '../api/articles'; interface MobileReaderViewProps { article: Article; @@ -11,117 +13,130 @@ interface MobileReaderViewProps { } export function MobileReaderView({ article, onClose, onToggleFavorite, onNext, onPrev }: MobileReaderViewProps) { - // Determine content to show: full content > summary > default message - let displayContent = article.content || article.summary || '

Aucun contenu disponible pour cet article.

'; + const [aiSummary, setAiSummary] = useState(article.ai_summary); + const [isSummarizing, setIsSummarizing] = useState(false); - // If we have a hero image, try to remove the first image from the content to avoid duplicates - if (article.image_url) { - displayContent = displayContent.replace(/]*>/, ''); - } + let displayContent = + article.content || article.summary || '

Aucun contenu disponible pour cet article.

'; + if (article.image_url) displayContent = displayContent.replace(/]*>/, ''); - // Swipe handlers const handlers = useSwipeable({ onSwipedLeft: () => onNext(), onSwipedRight: () => onPrev(), preventScrollOnSwipe: false, - trackMouse: true // Allow testing with mouse in devtools + trackMouse: true, }); + const handleSummarize = async () => { + setIsSummarizing(true); + try { + const res = await articlesApi.summarize(article.id); + setAiSummary(res.summary); + } catch (err) { + console.error('Failed to summarize:', err); + } finally { + setIsSummarizing(false); + } + }; + return ( -
-
e.stopPropagation()} > - {/* Mobile Header / Nav Indicator Hint */} -
- - {/* Hero Image Section */} {article.image_url && ( -
- {article.title} -
+
+ +
)} -
+
-
- {article.feed_title} - • - {article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }) : 'Aujourd\'hui'} +
+ {article.feed_title} + · + + {article.published_at + ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }) + : "Aujourd'hui"} +
- -

+

{article.title}

+ + {aiSummary ? ( +
+

✨ Résumé IA

+

{aiSummary}

+
+ ) : ( + + )}
- {/* Mobile Action Bar */} -
+
- - - - - - + {article.url && ( + + + + )}
-
-

- Swipe pour lire la suite -

+
+

Glissez pour lire la suite

-
+ - {/* Fixed Close Button for Mobile - Top Right */} -
- -
-
+ + ); } diff --git a/web/src/components/MobileTopBar.tsx b/web/src/components/MobileTopBar.tsx new file mode 100644 index 0000000..92ae66b --- /dev/null +++ b/web/src/components/MobileTopBar.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { feedsApi } from '../api/feeds'; +import { AddFeedModal } from './AddFeedModal'; +import { ThemeToggle } from './ThemeToggle'; + +interface MobileTopBarProps { + onSelectFeed: (feedId: string | null) => void; + selectedFeedId: string | null; + onEnterFocus: () => void; +} + +/** Compact navigation for small screens (the full Sidebar is hidden on mobile). */ +export function MobileTopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: MobileTopBarProps) { + const [isAddOpen, setIsAddOpen] = useState(false); + const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() }); + + return ( + <> +
+
+ +
+ + + +
+
+ + {/* Feed selector chips */} +
+ {[ + { id: null as string | null, label: 'Tous' }, + { id: 'favorites' as string | null, label: 'Favoris' }, + ...(feeds ?? []).map((f) => ({ id: f.id as string | null, label: f.title })), + ].map((item) => ( + + ))} +
+
+ + setIsAddOpen(false)} /> + + ); +} diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 158def5..f059719 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -6,9 +6,9 @@ export function ProtectedRoute() { if (isLoading) { return ( -
-
-

Chargement...

+
+
+

Chargement…

); } diff --git a/web/src/components/ReaderView.tsx b/web/src/components/ReaderView.tsx index fcfe788..a6f2bc3 100644 --- a/web/src/components/ReaderView.tsx +++ b/web/src/components/ReaderView.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; import { ShareButton } from './ShareButton'; import { type Article, articlesApi } from '../api/articles'; @@ -12,156 +13,150 @@ export function ReaderView({ article, onClose, onToggleFavorite }: ReaderViewPro const [aiSummary, setAiSummary] = useState(article.ai_summary); const [isSummarizing, setIsSummarizing] = useState(false); - // Determine content to show: full content > summary > default message - let displayContent = article.content || article.summary || '

Aucun contenu disponible pour cet article.

'; + let displayContent = + article.content || article.summary || '

Aucun contenu disponible pour cet article.

'; + if (article.image_url) displayContent = displayContent.replace(/]*>/, ''); - // If we have a hero image, try to remove the first image from the content to avoid duplicates - if (article.image_url) { - // Simple regex to remove the first img tag if it exists at the start of the content - // This is a heuristic and might need refinement, but handles the "duplicate" complaint common in RSS - displayContent = displayContent.replace(/]*>/, ''); - } + // Close on Escape + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const handleSummarize = async (e: React.MouseEvent) => { + e.stopPropagation(); + setIsSummarizing(true); + try { + const res = await articlesApi.summarize(article.id); + setAiSummary(res.summary); + } catch (err) { + console.error('Failed to summarize:', err); + } finally { + setIsSummarizing(false); + } + }; return ( -
-
e.stopPropagation()} > - - - {/* Close button for desktop */} + {/* Desktop close */} - {/* Hero Image Section */} + {/* Hero */} {article.image_url && ( -
- {article.title} -
+
+ +
)} -
+
-
- {article.feed_title} - • - {article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Aujourd\'hui'} +
+ {article.feed_title} + · + + {article.published_at + ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) + : "Aujourd'hui"} +
-

+

{article.title}

- {/* Smart Digest Section */} + {/* Smart Digest */} {aiSummary ? ( -
-

- ✨ Résumé -

-

- {aiSummary} -

+
+

✨ Résumé IA

+

{aiSummary}

) : ( -
- -
+ )}
-
-
+
+
- - Source d'origine - - - - + {article.url && ( + + Source + + + )}
-
-
F.
-

- FlowReader Lux Édition • 2026 -

+
+
F.
+

FlowReader · Édition 2026

-
+ - {/* Mobile close button overlay */} + {/* Mobile close */} -
+ ); } diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 9ed90c3..3b5004a 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,5 +1,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuthStore } from '../stores/authStore'; +import { authApi } from '../api/auth'; import { feedsApi } from '../api/feeds'; import { articlesApi } from '../api/articles'; import { AddFeedModal } from './AddFeedModal'; @@ -15,19 +17,13 @@ interface SidebarProps { export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMode }: SidebarProps) { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const queryClient = useQueryClient(); + const user = useAuthStore((s) => s.user); + const logoutStore = useAuthStore((s) => s.logout); - const { data: feeds } = useQuery({ - queryKey: ['feeds'], - queryFn: () => feedsApi.list(), - }); + const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() }); const markAllReadMutation = useMutation({ - mutationFn: () => { - if (selectedFeedId) { - return articlesApi.markAllRead(selectedFeedId); - } - return articlesApi.markAllReadGlobal(); - }, + mutationFn: () => (selectedFeedId ? articlesApi.markAllRead(selectedFeedId) : articlesApi.markAllReadGlobal()), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['feeds'] }); queryClient.invalidateQueries({ queryKey: ['articles'] }); @@ -38,190 +34,191 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod mutationFn: (id: string) => feedsApi.delete(id), onSuccess: (_, deletedId) => { queryClient.invalidateQueries({ queryKey: ['feeds'] }); - if (selectedFeedId === deletedId) { - onSelectFeed(null); - } + if (selectedFeedId === deletedId) onSelectFeed(null); }, }); const updateFeedMutation = useMutation({ mutationFn: ({ id, title }: { id: string; title: string }) => feedsApi.update(id, title), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['feeds'] }); - }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['feeds'] }), + }); + + const logoutMutation = useMutation({ + mutationFn: () => authApi.logout(), + onSuccess: () => { logoutStore(); window.location.href = '/login'; }, }); const handleDelete = (e: React.MouseEvent, 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) => { e.stopPropagation(); const newTitle = window.prompt('Renommer le flux :', currentTitle); if (newTitle && newTitle.trim() !== '' && newTitle !== currentTitle) { - updateFeedMutation.mutate({ id, title: newTitle }); + updateFeedMutation.mutate({ id, title: newTitle.trim() }); } }; + const navItem = (active: boolean) => + `w-full flex items-center justify-between px-4 py-2.5 rounded-xl text-left transition-all duration-300 ${ + active + ? 'bg-nature/10 text-nature border border-nature/20' + : 'text-paper-muted border border-transparent hover:text-nature hover:bg-nature/5' + }`; + + const dot = (active: boolean) => + `w-1.5 h-1.5 rounded-full bg-nature mr-3 transition-opacity ${active ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100'}`; + return ( <> -
-
-
-

Mode Zen

-

"Le silence est le bruit des esprits."

+
+ +
+ +
+ +
+ +

Mes flux

+ + {feeds && feeds.length > 0 ? feeds.map((feed) => ( +
+ +
+ )) : ( +

+ Aucun flux pour l'instant. Ajoutez-en un avec le bouton +. +

+ )} + + + {/* Footer: user + logout */} +
+
+
+

{user?.email ?? 'Compte'}

+

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

+
+
- setIsAddModalOpen(false)} - /> + setIsAddModalOpen(false)} /> ); } diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index cffefd6..9bc629c 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -1,37 +1,51 @@ import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; + +type Theme = 'light' | 'dark'; + +function getInitialTheme(): Theme { + const stored = localStorage.getItem('theme'); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} export function ThemeToggle() { - const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light'); + const [theme, setTheme] = useState(getInitialTheme); useEffect(() => { const root = window.document.documentElement; - if (theme === 'dark') { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } + root.classList.toggle('dark', theme === 'dark'); localStorage.setItem('theme', theme); }, [theme]); - const toggleTheme = () => { - setTheme(theme === 'light' ? 'dark' : 'light'); - }; + const isDark = theme === 'dark'; return ( ); } diff --git a/web/src/components/focus/FocusCard.tsx b/web/src/components/focus/FocusCard.tsx index 0b1f49d..474f855 100644 --- a/web/src/components/focus/FocusCard.tsx +++ b/web/src/components/focus/FocusCard.tsx @@ -6,75 +6,56 @@ interface FocusCardProps { } export function FocusCard({ article, isTop = false }: FocusCardProps) { - const timeToRead = article.content - ? Math.ceil(article.content.length / 1000) - : 1; + const timeToRead = article.content ? Math.max(1, Math.ceil(article.content.length / 1000)) : 1; + const excerpt = article.summary ? article.summary.replace(/<[^>]*>?/gm, '').slice(0, 150) : ''; return ( -
- {/* Image Section (Top 60%) */} -
+
+ {/* Cover (60%) */} +
{article.image_url ? ( -
+
) : ( - /* Fallback Gradient if no image */
-

+

{article.title} -

+
)} - - {/* Overlay Gradient for text readability */} -
- - {/* Badge: Time to Read */} -
+
+ ~{timeToRead} min -
+
- {/* Content Section (Bottom 40%) */} -
- {/* Source & Date */} -
- - - - {article.feed_title || 'Journal'} - + {/* Body (40%) */} +
+
+ + + {article.feed_title || 'Journal'} {new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', { - weekday: 'short', - hour: '2-digit', - minute: '2-digit' + weekday: 'short', hour: '2-digit', minute: '2-digit', })}
- {/* Title (if image present, otherwise shown in gradient header) */} {article.image_url && (

{article.title}

)} - {/* Excerpt */} - {article.summary && ( -

- {article.summary.replace(/<[^>]*>?/gm, '').substring(0, 140)}... -

+ {excerpt && ( +

{excerpt}…

)} - {/* Hint (only on top card) */} {isTop && ( -
- Double-cliquer pour lire • Glisser pour trier -
+

+ Double-clic pour lire · Glisser pour trier +

)}
diff --git a/web/src/components/focus/FocusCardStack.tsx b/web/src/components/focus/FocusCardStack.tsx index 032d481..92d20ee 100644 --- a/web/src/components/focus/FocusCardStack.tsx +++ b/web/src/components/focus/FocusCardStack.tsx @@ -15,10 +15,6 @@ interface FocusCardStackProps { onEmpty: () => void; } -// ------------------------------------------------------------------ -// Internal Component: SwipeableCard -// Isolates drag state per card to prevent "rebound" on unmount. -// ------------------------------------------------------------------ interface SwipeableCardProps { article: Article; index: number; @@ -29,144 +25,58 @@ interface SwipeableCardProps { isProcessing: boolean; } +/** A single draggable card. Drag state is local so unmount never "rebounds" the stack. */ function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef, isProcessing }: SwipeableCardProps) { const x = useMotionValue(0); const rotate = useTransform(x, [-200, 200], [-10, 10]); + const opacityRead = useTransform(x, [0, 150], [0, 1]); + const opacityKeep = useTransform(x, [-150, 0], [1, 0]); - // Background color based on drag direction (Keep/Read indicators) - const bgOpacityRead = useTransform(x, [0, 150], [0, 1]); - const bgOpacityKeep = useTransform(x, [-150, 0], [1, 0]); - - // Spring physics for smooth movement const xSpring = useSpring(x, { stiffness: 1000, damping: 50 }); const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 }); const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => { - if (!isTop || isProcessing) return; // Only top card is draggable - - if (active && Math.abs(mx) > 5) { - hasDraggedRef.current = true; - } + 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) { - // Swipe Validated - const isRight = mx > 0; - // Capture nothing here, we just fire action. - // The component unmounts with its current x/xSpring state intact. - // Motion handles the rest via 'exit' prop or just unmounting. - onAction(isRight ? 'read' : 'keep'); + onAction(mx > 0 ? 'read' : 'keep'); } else if (!active) { - // Snap Back x.set(0); setTimeout(() => { hasDraggedRef.current = false; }, 50); } else { - // Dragging x.set(mx); } }); - // Determine zIndex and scale based on index - const zIndex = 100 - index; const scale = 1 - index * 0.05; - const yOffset = index * 20; + const yOffset = index * 18; return ( Wait, xSpring is horizontal movement applied to Y? - // Ah, the original code had: y: isTop ? xSpring : i * 20. - // That seems weird if xSpring comes from x (horizontal). - // Maybe it was intended to give a slight vertical lift on drag? - // Or maybe xSpring was actually ySpring in previous code? - // In my reading of previous code: const x = useMotionValue(0); const xSpring = useSpring(x...); - // So y was bound to xSpring. That means swiping right moves the card DOWN? - // That might be a typo in original code or a specific effect. - // Let's stick to X for horizontal. - // Let's look at original again: `y: isTop ? xSpring : i * 20`. - // If I swipe right (x > 0), y increases (card goes down). - // If I swipe left (x < 0), y decreases (card goes up). - // Use 'x' for 'x'. And 'y' for... 0 if top? - // Let's use xSpring for 'x' property. - x: isTop ? xSpring : 0, rotate: isTop ? rotateSpring : 0, - zIndex: zIndex, + zIndex: 100 - index, cursor: isTop ? 'grab' : 'default', + boxShadow: 'var(--shadow-float)', }} - {...(isTop ? (bind() as any) : {})} // Bind gesture only if top - onDoubleClick={() => { - if (isTop && !hasDraggedRef.current) { - onOpen(); - } - }} - initial={{ scale: scale, y: yOffset, x: 0 }} - animate={{ scale: scale, y: yOffset, x: 0 }} // Re-center if index changes (but this component is unique by key) - // Correction: When index changes (e.g. going from 1 to 0), we want it to animate to new position. - // But here we are mapping articles. - // If we use 'key={article.id}', the component identity is preserved. - // So if card at index 1 becomes index 0, it animates: - // scale: 0.95 -> 1 - // y: 20 -> 0 - // x: 0 -> 0 - - exit={{ - // When unmounting (swiped out): - // We want it to keep its current x/rotation roughly, and fade out. - // Since xSpring holds the value, we don't need to force x in exit if we rely on the fact - // that unmounting preserves style for the exit duration? - // Actually, Framer Motion exit prop overrides. - // If we want to freeze, we can use a custom exit. - // But the user complained about rebound because 'x' changed. - // Here, 'x' is LOCAL. And we NEVER reset it if we trigger action. - // So 'xSpring' will stay at ~500 or -500. - // So we can just fade out. - opacity: 0, - scale: 0.9, - transition: { duration: 0.2 } - }} + {...(isTop ? (bind() as Record) : {})} + onDoubleClick={() => { if (isTop && !hasDraggedRef.current) onOpen(); }} + initial={{ scale, y: yOffset, x: 0 }} + animate={{ scale, y: yOffset, x: 0 }} + exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }} > - {/* Visual Indicators embedded in card to move with it? - Actually original code had them in background. - If we want them ON the card, we put them here. - If in background, parent handles. - Original code: Indicators were OUTSIDE the card stack, using 'bgOpacityRead' derived from 'x'. - Since 'x' is now local, the parent doesn't know about it. - We should either: - 1. Expose 'x' to parent (complex). - 2. Put indicators INSIDE the card (but absolute positioned to fill screen? No, clipped). - 3. Put indicators INSIDE the card but visual only on the card itself (like Tinder stamps). - - Let's emulate the original "Background Indicators" but maybe just on the card itself is better for encapsulation? - Or we can use a callback `onDrag` to update parent state? Performance hit. - - Simpler: The user wanted "Swipe card". The indicators were "Background". - If we lose the background indicators, is it bad? - The original code had: - - - With isolated state, we can't easily drive standard DOM elements outside the component. - But we CAN put the indicators INSIDE the card container? - No, because the card rotates and moves. - - Alternative: We only show indicators ON THE CARD (Overlay). - Like a "NOPE" or "LIKE" stamp on the card content. - Let's add that. It's often better UX anyway. - */} - - {/* Overlay Indicators (Like Tinder) */} - -
- J'M + {/* Tinder-style stamps */} + +
+ J'ai lu
- - -
+ +
Garder
@@ -176,21 +86,13 @@ function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef, ); } -// ------------------------------------------------------------------ -// Main Component -// ------------------------------------------------------------------ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, onEmpty }: FocusCardStackProps) { const [currentIndex, setCurrentIndex] = useState(0); const [readingArticle, setReadingArticle] = useState
(null); const isMobile = useIsMobile(); const [isProcessing, setIsProcessing] = useState(false); - - // Shared ref just for the double click check logic if needed, - // but actually each card manages its own drag state. - // We pass this ref down so the active card can write to it. const hasDraggedRef = useRef(false); - // Visible stack size const visibleArticles = articles.slice(currentIndex, currentIndex + 3); const topArticle = visibleArticles[0]; @@ -199,38 +101,25 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, setIsProcessing(true); if (action === 'undo') { - if (currentIndex > 0) { - setCurrentIndex(prev => Math.max(0, prev - 1)); - } + setCurrentIndex((prev) => Math.max(0, prev - 1)); } else if (action === 'read') { - onMarkRead(topArticle.id); - setCurrentIndex(prev => prev + 1); + if (topArticle) onMarkRead(topArticle.id); + setCurrentIndex((prev) => prev + 1); } else { - onKeep(topArticle.id); - setCurrentIndex(prev => prev + 1); + if (topArticle) onKeep(topArticle.id); + setCurrentIndex((prev) => prev + 1); } - - // Lock for animation duration - setTimeout(() => setIsProcessing(false), 500); + setTimeout(() => setIsProcessing(false), 450); }; - // Callbacks for the card components - const handleCardAction = (action: 'read' | 'keep') => { - handleAction(action); - }; - - // Check for empty stack useEffect(() => { - if (currentIndex >= articles.length && articles.length > 0) { - onEmpty(); - } + if (currentIndex >= articles.length && articles.length > 0) onEmpty(); }, [currentIndex, articles.length, onEmpty]); if (!topArticle) return null; return (
- {/* Reading View Modal */} {readingArticle && ( isMobile ? ( @@ -238,93 +127,78 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, article={readingArticle} onClose={() => setReadingArticle(null)} onToggleFavorite={onToggleFavorite} - onNext={() => { - handleAction('read'); - setReadingArticle(null); - }} - onPrev={() => { - handleAction('keep'); - setReadingArticle(null); - }} + onNext={() => { handleAction('read'); setReadingArticle(null); }} + onPrev={() => { handleAction('keep'); setReadingArticle(null); }} /> ) : ( - setReadingArticle(null)} - onToggleFavorite={onToggleFavorite} - /> + setReadingArticle(null)} onToggleFavorite={onToggleFavorite} /> ) )} - {/* Note: Background indicators removed in favor of Card Overlays for isolated state cleanliness */} - - {/* Card Stack */} + {/* Stack */}
- {visibleArticles.map((article, i) => { - return ( - setReadingArticle(article)} - hasDraggedRef={hasDraggedRef} - isProcessing={isProcessing} - /> - ); - })} + {visibleArticles.map((article, i) => ( + setReadingArticle(article)} + hasDraggedRef={hasDraggedRef} + isProcessing={isProcessing} + /> + ))}
-
- {/* Garder */} + {/* Controls */} +
- {/* Undo */} - {/* J'ai lu */}
- {/* Progress Counter */} + {/* Counter */}
-
- {articles.length - currentIndex} Restants -
+ + {articles.length - currentIndex} restants +
); diff --git a/web/src/components/focus/FocusEmptyState.tsx b/web/src/components/focus/FocusEmptyState.tsx index 07d3384..faf549b 100644 --- a/web/src/components/focus/FocusEmptyState.tsx +++ b/web/src/components/focus/FocusEmptyState.tsx @@ -1,30 +1,32 @@ +import { motion } from 'framer-motion'; + interface FocusEmptyStateProps { onBack: () => void; } export function FocusEmptyState({ onBack }: FocusEmptyStateProps) { return ( -
+
-
- 🎉 +
+ 🌿
-
+
✓

C'est tout pour le moment !

-

Vous êtes à jour sur vos lectures.

+

Vous êtes à jour sur vos lectures.

- -
+ + ); } diff --git a/web/src/index.css b/web/src/index.css index 18bc03a..4c98c77 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,229 +1,302 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap'); @import "tailwindcss"; +/* ============================================================ + FlowReader — Design System "Éditorial Botanique" + Light: Organic Warmth · Dark: Deep Forest + ============================================================ */ + @theme { - --color-carbon: #F9F7F1; - --color-carbon-light: #FFFFFF; - --color-carbon-dark: #E7E5E4; - --color-nature: #166534; - --color-nature-light: #15803d; - --color-nature-dark: #0A0F0B; - /* Deep Forest Base */ - --color-earth: #A0522D; - --color-paper-white: #1C1917; - --color-paper-muted: #57534E; + /* Surfaces — warm paper */ + --color-carbon: #F7F4EC; /* app background */ + --color-carbon-light: #FFFFFF; /* raised surface / cards */ + --color-carbon-dark: #E9E4D8; /* borders / sunken */ - --font-serif: "Playfair Display", serif; + /* Brand — forest green */ + --color-nature: #15643A; + --color-nature-light: #1E8A4F; + --color-nature-dark: #0A2E1B; + + /* Accent — terracotta (used sparingly) */ + --color-earth: #B45B3E; + --color-earth-light: #C9714F; + + /* Ink */ + --color-paper-white: #1B1714; /* primary text */ + --color-paper-muted: #6B645B; /* secondary text */ + + /* Semantic */ + --color-ring: #1E8A4F; + --color-danger: #C2410C; + + /* Typography */ + --font-serif: "Playfair Display", Georgia, serif; --font-sans: "Inter", system-ui, sans-serif; - --font-reading: "Lora", serif; + --font-reading: "Lora", Georgia, serif; - --line-height-magazine: 1.8; + /* Radii scale */ + --radius-xs: 0.375rem; + --radius-sm: 0.625rem; + --radius-md: 0.875rem; + --radius-lg: 1.25rem; + --radius-xl: 1.75rem; + + --line-height-magazine: 1.85; + + /* Shadows */ + --shadow-soft: 0 2px 8px -2px rgba(27, 23, 20, 0.08), 0 4px 24px -6px rgba(27, 23, 20, 0.06); + --shadow-lift: 0 12px 32px -12px rgba(27, 23, 20, 0.18); + --shadow-float: 0 24px 60px -20px rgba(10, 46, 27, 0.28); } -/* Dark Mode Variables */ +/* ---------- Dark theme: Deep Forest ---------- */ .dark { --color-carbon: #0A0F0B; - --color-carbon-light: #141C16; - --color-carbon-dark: #050705; - --color-nature: #22C55E; - --color-nature-light: #4ADE80; - --color-paper-white: #F9F7F1; - --color-paper-muted: #A8A29E; + --color-carbon-light: #12190F; + --color-carbon-dark: #060A06; + + --color-nature: #34D27B; + --color-nature-light: #5DE89A; + --color-nature-dark: #0E1C12; + + --color-earth: #D98060; + --color-earth-light: #E59A7E; + + --color-paper-white: #F4F1E8; + --color-paper-muted: #9A958A; + + --color-ring: #34D27B; + --color-danger: #F87045; + + --shadow-soft: 0 2px 10px -2px rgba(0, 0, 0, 0.5); + --shadow-lift: 0 16px 40px -16px rgba(0, 0, 0, 0.6); + --shadow-float: 0 28px 70px -24px rgba(0, 0, 0, 0.7); } +/* ============================================================ + Custom utilities + ============================================================ */ @utility nature-glow { - box-shadow: 0 4px 20px rgba(22, 101, 52, 0.15); + box-shadow: 0 4px 22px rgba(21, 100, 58, 0.18); } - .dark .nature-glow { - box-shadow: 0 4px 25px rgba(34, 197, 94, 0.2); + box-shadow: 0 4px 26px rgba(52, 210, 123, 0.22); } @utility paper-panel { - background: #FFFFFF; - border: 1px solid #E7E5E4; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); -} - -.dark .paper-panel { background: var(--color-carbon-light); - border: 1px solid rgba(255, 255, 255, 0.05); - box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3); + border: 1px solid color-mix(in oklab, var(--color-paper-muted) 16%, transparent); + box-shadow: var(--shadow-soft); } +@utility text-balance { + text-wrap: balance; +} + +/* ============================================================ + Base + ============================================================ */ @layer base { + *, + *::before, + *::after { + border-color: color-mix(in oklab, var(--color-paper-muted) 18%, transparent); + } + + html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; + } + body { - @apply bg-carbon text-paper-white font-sans antialiased selection:bg-nature/20; + @apply bg-carbon text-paper-white font-sans antialiased; margin: 0; min-height: 100vh; - border-top: 4px solid var(--color-nature); - /* Editorial Touch */ - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.2'/%3E%3C/svg%3E"); + border-top: 3px solid var(--color-nature); + background-image: + radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 8%, transparent), transparent), + url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.14'/%3E%3C/svg%3E"); background-blend-mode: multiply; - background-repeat: repeat; - transition: background-color 0.5s ease, color 0.5s ease; + background-repeat: no-repeat, repeat; + transition: background-color 0.4s ease, color 0.4s ease; } .dark body { - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.05'/%3E%3C/svg%3E"); - background-blend-mode: screen; + 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 { @apply font-serif tracking-tight leading-tight text-paper-white; } a { - @apply transition-all duration-300 decoration-nature/30 underline-offset-4 hover:decoration-nature; + @apply transition-colors duration-300; } - ::-webkit-scrollbar { - width: 6px; + /* Accessible focus — keyboard only */ + :focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: 2px; + border-radius: var(--radius-xs); } - - ::-webkit-scrollbar-track { - background: transparent; + :focus:not(:focus-visible) { + outline: none; } + /* Scrollbars */ + ::-webkit-scrollbar { width: 8px; height: 8px; } + ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background: rgba(87, 83, 78, 0.2); - border-radius: 10px; + background: color-mix(in oklab, var(--color-paper-muted) 28%, transparent); + border-radius: 999px; + } + ::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklab, var(--color-paper-muted) 45%, transparent); } - ::-webkit-scrollbar-thumb:hover { - background: rgba(87, 83, 78, 0.4); + /* Respect reduced motion */ + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } } +/* ============================================================ + Components + ============================================================ */ @layer components { - .magazine-grid { - display: grid; - grid-template-columns: repeat(1, minmax(0, 1fr)); - gap: 2rem; - padding: 2rem 0; - } - - @media (min-width: 768px) { - .magazine-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - - @media (min-width: 1024px) { - .magazine-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - } - - .magazine-card { - @apply relative transition-all duration-500 ease-out hover:-translate-y-1; + /* Eyebrow / kicker label */ + .eyebrow { + @apply text-[10px] uppercase tracking-[0.35em] font-bold text-nature; } + /* Buttons */ .btn-primary { - @apply bg-nature text-white font-medium px-6 py-2.5 rounded-lg hover:bg-nature-light hover:shadow-lg transition-all active:scale-95 text-xs uppercase tracking-widest border border-transparent; + @apply inline-flex items-center justify-center gap-2 bg-nature text-white font-bold + px-6 py-2.5 rounded-full text-[11px] uppercase tracking-[0.18em] + border border-transparent transition-all duration-300 + hover:bg-nature-light hover:shadow-[0_8px_24px_-8px_rgba(21,100,58,0.5)] + active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none; + } + + .btn-secondary { + @apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-bold + px-6 py-2.5 rounded-full text-[11px] uppercase tracking-[0.18em] + border border-nature/30 transition-all duration-300 + hover:bg-nature hover:text-white hover:border-nature + active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none; } .btn-ghost { - @apply text-paper-muted hover:text-nature hover:bg-nature/5 px-4 py-2 rounded-lg transition-all text-sm 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 + hover:text-nature hover:bg-nature/5; } + .icon-btn { + @apply inline-flex items-center justify-center w-9 h-9 rounded-full text-nature + border border-nature/15 transition-all duration-300 + hover:bg-nature/10 hover:border-nature/30 active:scale-95; + } + + /* Inputs */ .input-field { - @apply bg-white border border-carbon-dark text-paper-white px-4 py-3 rounded-lg focus:border-nature/50 focus:ring-1 focus:ring-nature/50 outline-none transition-all placeholder:text-paper-muted/50 shadow-sm transition-all; + @apply w-full bg-carbon-light text-paper-white px-4 py-3 rounded-xl + border border-paper-muted/20 shadow-sm transition-all duration-200 + placeholder:text-paper-muted/45 + focus:border-nature/50 focus:ring-2 focus:ring-nature/25 outline-none; } - .dark .input-field { - @apply bg-carbon-light border-white/5 text-paper-white placeholder:text-paper-muted/30; + /* Chip / badge */ + .chip { + @apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full + text-[10px] font-bold uppercase tracking-[0.18em] + bg-nature/10 text-nature; } + /* Surface card */ + .surface-card { + @apply bg-carbon-light rounded-2xl border border-paper-muted/12; + box-shadow: var(--shadow-soft); + } + + /* ---------- Magazine reading content ---------- */ .magazine-content { - @apply font-reading leading-relaxed text-paper-white/90; + @apply font-reading text-paper-white/90; + line-height: var(--line-height-magazine); } - - .magazine-content p { - @apply mb-8; - } - + .magazine-content p { @apply mb-7; } .magazine-content a { - @apply text-nature border-b border-nature/20 hover:border-nature transition-all no-underline font-medium; + @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-6 mt-12; + @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-xl my-12 shadow-lg mx-auto; + @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-8 ml-6 space-y-4; + .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; } - .magazine-content li { - @apply pl-2 marker:text-nature; + /* Drop cap for immersive reader */ + .drop-cap::first-letter { + @apply font-serif text-nature float-left mr-3 mt-2; + font-size: 4.5rem; + line-height: 3rem; } } +/* ============================================================ + Keyframes & motion utilities + ============================================================ */ @layer utilities { - .animate-bounce-slow { - animation: bounce-slow 3s infinite; - } + .text-shadow-sm { text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); } - .animate-fade-in { - animation: fade-in 0.5s ease-out forwards; - } + .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } + .no-scrollbar::-webkit-scrollbar { display: none; } - .text-shadow-sm { - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - } - - @keyframes bounce-slow { - - 0%, - 100% { - transform: translateY(-5%); - animation-timing-function: cubic-bezier(0.8, 0, 1, 1); - } - - 50% { - transform: translateY(0); - animation-timing-function: cubic-bezier(0, 0, 0.2, 1); - } - } + .animate-fade-in { animation: fade-in 0.5s ease-out forwards; } + .animate-rise { animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; } + .animate-bounce-slow { animation: bounce-slow 3s infinite; } + .animate-shake { animation: shake 0.4s ease-in-out; } @keyframes fade-in { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } } - - .custom-scrollbar::-webkit-scrollbar { - width: 8px; + @keyframes rise { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } } - - .custom-scrollbar::-webkit-scrollbar-track { - background: transparent; + @keyframes bounce-slow { + 0%, 100% { transform: translateY(-5%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } + 50% { transform: translateY(0); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } } - - .custom-scrollbar::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.1); - border-radius: 4px; + @keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } } - - .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.2); - } -} \ No newline at end of file +} diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index 42293e3..e9f5c87 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Sidebar } from '../components/Sidebar'; +import { MobileTopBar } from '../components/MobileTopBar'; import { DashboardPage } from '../pages/DashboardPage'; import { FocusPage } from '../pages/FocusPage'; @@ -15,13 +16,18 @@ export function RootLayout() { onEnterFocus={() => setIsFocusMode(true)} isFocusMode={isFocusMode} /> + {isFocusMode ? ( setIsFocusMode(false)} /> ) : ( - setIsFocusMode(true)} - /> +
+ setIsFocusMode(true)} + /> + setIsFocusMode(true)} /> +
)}
); diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 20cf083..e565778 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -6,34 +6,22 @@ import { feedsApi } from '../api/feeds'; import { ArticleCard } from '../components/ArticleCard'; import { ReaderView } from '../components/ReaderView'; import { MobileReaderView } from '../components/MobileReaderView'; +import { useIsMobile } from '../hooks/useIsMobile'; import { useWebsocket } from '../hooks/useWebsocket'; import type { Article } from '../api/articles'; -const breakpointColumnsObj = { - default: 3, - 1100: 2, - 700: 1 -}; +const breakpointColumnsObj = { default: 3, 1100: 2, 700: 1 }; +const ITEMS_PER_PAGE = 24; interface DashboardPageProps { selectedFeedId: string | null; onEnterFocus: () => void; } -const ITEMS_PER_PAGE = 24; +type ArticlePages = { pages: Article[][] }; -// Helper Hook for Responsive Split -function useIsMobile() { - const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches); - - useEffect(() => { - const media = window.matchMedia('(max-width: 768px)'); - const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches); - media.addEventListener('change', listener); - return () => media.removeEventListener('change', listener); - }, []); - - return isMobile; +function timestamp(a: Article): number { + return new Date(a.published_at || a.created_at).getTime(); } export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) { @@ -43,118 +31,86 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro const [debouncedQuery, setDebouncedQuery] = useState(''); const isMobile = useIsMobile(); const loadMoreRef = useRef(null); + const mainRef = useRef(null); + const [showScrollTop, setShowScrollTop] = useState(false); - // Debounce search query useEffect(() => { - const timer = setTimeout(() => { - setDebouncedQuery(searchQuery); - }, 500); + const timer = setTimeout(() => setDebouncedQuery(searchQuery), 400); return () => clearTimeout(timer); }, [searchQuery]); - // Real-time sync useWebsocket(); - const { - data, - isLoading, - fetchNextPage, - hasNextPage, - isFetchingNextPage - } = useInfiniteQuery({ - queryKey: ['articles', selectedFeedId, debouncedQuery], + const queryKey = ['articles', selectedFeedId, debouncedQuery]; + + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey, queryFn: ({ pageParam = 0 }) => { - const options = { - limit: ITEMS_PER_PAGE, - offset: pageParam, - }; - - if (debouncedQuery) { - return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam); - } - - if (selectedFeedId === 'favorites') { - return articlesApi.list({ ...options, favorite: true }); - } + const options = { limit: ITEMS_PER_PAGE, offset: pageParam }; + if (debouncedQuery) return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam); + if (selectedFeedId === 'favorites') return articlesApi.list({ ...options, favorite: true }); return articlesApi.list({ ...options, feed_id: selectedFeedId || undefined }); }, initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (lastPage.length < ITEMS_PER_PAGE) return undefined; - return allPages.length * ITEMS_PER_PAGE; - }, + getNextPageParam: (lastPage, allPages) => + lastPage.length < ITEMS_PER_PAGE ? undefined : allPages.length * ITEMS_PER_PAGE, }); - const articles = data?.pages.flat() || []; const sortedArticles = useMemo(() => { - return [...articles].sort((a, b) => { - // First, sort by read status: unread (false) first - if (a.is_read !== b.is_read) { - return a.is_read ? 1 : -1; - } - // If same read status, sort by date descending - const dateA = a.published_at || a.created_at; - const dateB = b.published_at || b.created_at; - return new Date(dateB) - new Date(dateA); + 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); }); - }, [articles]); + }, [data]); - // Intersection Observer for Infinite Scroll + const unreadCount = useMemo(() => sortedArticles.filter((a) => !a.is_read).length, [sortedArticles]); + + // Infinite scroll useEffect(() => { const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); }, { threshold: 0.1 } ); - - if (loadMoreRef.current) { - observer.observe(loadMoreRef.current); - } - + if (loadMoreRef.current) observer.observe(loadMoreRef.current); return () => observer.disconnect(); }, [hasNextPage, fetchNextPage, isFetchingNextPage]); + // Back-to-top visibility + useEffect(() => { + const el = mainRef.current; + if (!el) return; + const onScroll = () => setShowScrollTop(el.scrollTop > 900); + el.addEventListener('scroll', onScroll); + return () => el.removeEventListener('scroll', onScroll); + }, []); + const refreshMutation = useMutation({ mutationFn: () => feedsApi.refresh(), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['articles'] }); - }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }), }); const toggleReadMutation = useMutation({ mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) => is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id), onMutate: async ({ id, is_read }) => { - const queryKey = ['articles', selectedFeedId, debouncedQuery]; await queryClient.cancelQueries({ queryKey }); const previousData = queryClient.getQueryData(queryKey); - - queryClient.setQueryData(queryKey, (old: any) => { + queryClient.setQueryData(queryKey, (old: ArticlePages | undefined) => { if (!old) return old; return { ...old, - pages: old.pages.map((page: Article[]) => - page.map((article) => - article.id === id ? { ...article, is_read: is_read } : article - ) - ), + pages: old.pages.map((page) => page.map((a) => (a.id === id ? { ...a, is_read } : a))), }; }); - return { previousData }; }, - onError: (_err, _newTodo, context) => { - const queryKey = ['articles', selectedFeedId, debouncedQuery]; - if (context?.previousData) { - queryClient.setQueryData(queryKey, context.previousData); - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['articles'] }); + onError: (_e, _v, context) => { + if (context?.previousData) queryClient.setQueryData(queryKey, context.previousData); }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }), }); const toggleFavoriteMutation = useMutation({ @@ -162,156 +118,116 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }), }); - const [showScrollTop, setShowScrollTop] = useState(false); - const mainRef = useRef(null); - - // Visibility logic for "Back to Top" button - useEffect(() => { - const handleScroll = (e: Event) => { - const target = e.target as HTMLElement; - setShowScrollTop(target.scrollTop > 1000); - }; - const mainElement = mainRef.current; - if (mainElement) { - mainElement.addEventListener('scroll', handleScroll); - } - return () => mainElement?.removeEventListener('scroll', handleScroll); - }, []); - - const scrollToTop = () => { - mainRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); - }; + const heading = debouncedQuery + ? `Résultats : « ${debouncedQuery} »` + : selectedFeedId === 'favorites' + ? 'Mes favoris' + : selectedFeedId + ? 'Archives du flux' + : 'La Une'; if (isLoading) { return ( -
-
-

Composition de votre édition...

+
+
+

Composition de votre édition…

); } return ( -
- {/* Back to Top Button */} +
+ {/* Back to top */} -
- {/* Header */} -
-
-
-

Édition du jour

-

- {debouncedQuery ? `Résultats : "${debouncedQuery}"` : selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'} +
+ {/* Masthead */} +
+
+
+

Édition du jour

+

+ {heading}

+

+ {new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} + {unreadCount > 0 && · {unreadCount} non lus} +

-
-
-

- {new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} -

-

- Volume IX • No. 42 -

-
-
-
-
- - - -
- setSearchQuery(e.target.value)} - className="pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-full text-[10px] text-paper-white placeholder-paper-muted/40 font-black tracking-widest focus:outline-none focus:ring-1 focus:ring-nature focus:border-nature transition-all w-48 focus:w-72" - /> - {searchQuery && ( - - )} -
- + +
+
+ + + + 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" + />
+
- {/* Focus Mode CTA Widget - Compact Version */} - {!selectedFeedId && sortedArticles && sortedArticles.length > 0 && ( -
- {/* decorative circles */} -
-
- -
-
- - Nouveauté - -
-

- Votre session de lecture -

-

- {sortedArticles.filter(a => !a.is_read).length} articles non lus. Passez en mode Focus. -

-
- -
- -
-
+ {/* Focus CTA */} + {!selectedFeedId && !debouncedQuery && unreadCount > 0 && ( + )} {/* Grid */} {sortedArticles.length > 0 ? ( <> - - {sortedArticles.map((article, index) => ( -
+ + {sortedArticles.map((article) => ( +
{ - setSelectedArticle(article); - if (!article.is_read) { - toggleReadMutation.mutate({ id: article.id, is_read: true }); - } + onClick={(a) => { + setSelectedArticle(a); + if (!a.is_read) toggleReadMutation.mutate({ id: a.id, is_read: true }); }} onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })} onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} @@ -320,31 +236,30 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro ))} - {/* Infinite Scroll Sentinel */} -
+
{isFetchingNextPage ? ( <> -
-

Chargement supplémentaire...

+
+

Chargement…

) : hasNextPage ? ( -

Défilez pour découvrir la suite

+

Défilez pour la suite

) : ( -
-
-

Fin de votre édition

-
+
+ +

Fin de l'édition

+
)}
) : ( -
-

- {debouncedQuery ? `Aucun article ne correspond à "${debouncedQuery}".` : 'Votre bibliothèque est vide.'} +

+

+ {debouncedQuery ? `Aucun article pour « ${debouncedQuery} ».` : 'Votre bibliothèque est vide.'}

-

- {debouncedQuery ? "Tentez une recherche plus large." : "Ajoutez un flux pour commencer votre lecture."} +

+ {debouncedQuery ? 'Tentez une recherche plus large.' : 'Ajoutez un flux pour commencer.'}

)} @@ -357,14 +272,11 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro onClose={() => setSelectedArticle(null)} onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} onNext={() => { - if (!sortedArticles) return; - const idx = sortedArticles.findIndex(a => a.id === selectedArticle.id); + const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id); if (idx < sortedArticles.length - 1) { - const nextArticle = sortedArticles[idx + 1]; - setSelectedArticle(nextArticle); - if (!nextArticle.is_read) { - toggleReadMutation.mutate({ id: nextArticle.id, is_read: true }); - } + const next = sortedArticles[idx + 1]; + setSelectedArticle(next); + if (!next.is_read) toggleReadMutation.mutate({ id: next.id, is_read: true }); } else if (hasNextPage) { fetchNextPage(); } else { @@ -372,12 +284,8 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro } }} onPrev={() => { - if (!sortedArticles) return; - const idx = sortedArticles.findIndex(a => a.id === selectedArticle.id); - if (idx > 0) { - const prevArticle = sortedArticles[idx - 1]; - setSelectedArticle(prevArticle); - } + const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id); + if (idx > 0) setSelectedArticle(sortedArticles[idx - 1]); }} /> ) : ( diff --git a/web/src/pages/FocusPage.tsx b/web/src/pages/FocusPage.tsx index f78227a..5381e70 100644 --- a/web/src/pages/FocusPage.tsx +++ b/web/src/pages/FocusPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { articlesApi } from '../api/articles'; +import { articlesApi, type Article } from '../api/articles'; import { FocusCardStack } from '../components/focus/FocusCardStack'; import { FocusEmptyState } from '../components/focus/FocusEmptyState'; @@ -12,102 +12,68 @@ export function FocusPage({ onExit }: FocusPageProps) { const queryClient = useQueryClient(); const [isComplete, setIsComplete] = useState(false); - // Fetch ONLY unread articles const { data: articles, isLoading } = useQuery({ queryKey: ['articles', 'focus-mode'], - queryFn: () => articlesApi.list({ unread: true, limit: 100 }), // Cap at 100 for performance - staleTime: 0, // Always fresh for focus mode + queryFn: () => articlesApi.list({ unread: true, limit: 100 }), + staleTime: 0, }); - // Stable deck state to prevent UI jumping when articles are marked read - const [deck, setDeck] = useState([]); - - // Initialize/Append deck when data arrives - if (articles && (!deck || deck.length === 0)) { + // Snapshot the unread set once into a stable deck for the whole session, + // so marking articles read mid-session never reshuffles the stack. This is + // the React-endorsed "adjust state during render" pattern (guarded to run + // exactly once), not an effect. + const [deck, setDeck] = useState([]); + const [seeded, setSeeded] = useState(false); + if (!seeded && articles && articles.length > 0) { setDeck(articles); + setSeeded(true); } - // Note: A more robust implementation would append new unique items if we implemented pagination/infinite scroll, - // but for now, snapshotting the initial load is sufficient for the "Focus Session" concept. - // Mutation to mark read - const markReadMutation = useMutation({ - mutationFn: (id: string) => articlesApi.markRead(id), - onSuccess: () => { - // Invalidate main lists to sync up sidebar, but our local 'deck' state won't change - queryClient.invalidateQueries({ queryKey: ['articles'] }); - queryClient.invalidateQueries({ queryKey: ['feeds'] }); - }, - }); - - // Mutation to toggle favorite - const toggleFavoriteMutation = useMutation({ - mutationFn: (id: string) => articlesApi.toggleFavorite(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['articles'] }); - queryClient.invalidateQueries({ queryKey: ['feeds'] }); - }, - }); - - const handleMarkRead = (id: string) => { - markReadMutation.mutate(id); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['articles'] }); + queryClient.invalidateQueries({ queryKey: ['feeds'] }); }; - const handleToggleFavorite = (id: string) => { - toggleFavoriteMutation.mutate(id); - }; + const markReadMutation = useMutation({ mutationFn: (id: string) => articlesApi.markRead(id), onSuccess: invalidate }); + const toggleFavoriteMutation = useMutation({ mutationFn: (id: string) => articlesApi.toggleFavorite(id), onSuccess: invalidate }); - const handleKeep = (id: string) => { - // Do nothing api-wise, just skip. - console.log('Skipped:', id); - }; - - if (isLoading && (!deck || deck.length === 0)) { + if (isLoading && deck.length === 0) { return ( -
-
+
+
); } - // Filter out locally read? No, we handle that in stack index. - // If deck is empty - if (!deck || deck.length === 0) { - return ; - } - - if (isComplete) { - return ; + if (deck.length === 0 || isComplete) { + return ( +
+ +
+ ); } return ( -
- {/* Header */} +
-
- - Mode Focus +
+ + Mode Focus
-
- {/* Main Deck Area */}
markReadMutation.mutate(id)} + onKeep={() => { /* skip — no API action */ }} + onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} onEmpty={() => setIsComplete(true)} />
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 0cac781..9bfdf51 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,17 +1,22 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; import { authApi } from '../api/auth'; import { ApiError } from '../api/client'; import { useAuthStore } from '../stores/authStore'; +import { AuthShell } from '../components/AuthShell'; export function LoginPage() { const navigate = useNavigate(); + const location = useLocation(); const setUser = useAuthStore((state) => state.setUser); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const flashMessage = (location.state as { message?: string } | null)?.message; + const loginMutation = useMutation({ mutationFn: authApi.login, onSuccess: (data) => { @@ -19,11 +24,7 @@ export function LoginPage() { navigate('/'); }, onError: (err: Error) => { - if (err instanceof ApiError) { - setError(err.message); - } else { - setError('Échec de la connexion. Veuillez réessayer.'); - } + setError(err instanceof ApiError ? err.message : 'Échec de la connexion. Veuillez réessayer.'); }, }); @@ -34,70 +35,64 @@ export function LoginPage() { }; return ( -
-
-
-

FlowReader

-

Magazine Personnel

+ +
+ {flashMessage && !error && ( +

+ {flashMessage} +

+ )} + {error && ( +

+ {error} +

+ )} + +
+ + setEmail(e.target.value)} + className="input-field" + placeholder="vous@exemple.com" + required + autoFocus + />
- - {error && ( -
- {error} -
- )} +
+ + setPassword(e.target.value)} + className="input-field" + placeholder="••••••••" + required + /> +
-
- - setEmail(e.target.value)} - className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light" - placeholder="vous@exemple.com" - required - autoFocus - /> -
+ + {loginMutation.isPending ? 'Authentification…' : 'Continuer'} + +
-
- - setPassword(e.target.value)} - className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light" - placeholder="••••••••" - required - /> -
- - - - -

- Nouveau chez FlowReader ?{' '} - -

-
-
+

+ Nouveau chez FlowReader ?{' '} + +

+ ); } diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx index 0adc9c6..9091ec0 100644 --- a/web/src/pages/RegisterPage.tsx +++ b/web/src/pages/RegisterPage.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; import { authApi } from '../api/auth'; import { ApiError } from '../api/client'; +import { AuthShell } from '../components/AuthShell'; export function RegisterPage() { const navigate = useNavigate(); @@ -17,107 +19,90 @@ export function RegisterPage() { navigate('/login', { state: { message: 'Compte créé ! Vous pouvez vous connecter.' } }); }, onError: (err: Error) => { - if (err instanceof ApiError) { - setError(err.message); - } else { - setError('L\'inscription a échoué. Veuillez réessayer.'); - } + setError(err instanceof ApiError ? err.message : "L'inscription a échoué. Veuillez réessayer."); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(''); - if (password !== confirmPassword) { - setError('Les mots de passe ne correspondent pas'); + setError('Les mots de passe ne correspondent pas.'); return; } - registerMutation.mutate({ email, password }); }; return ( -
-
-
-

FlowReader

-

Rejoindre l'Édition

+ +
+ {error && ( +

+ {error} +

+ )} + +
+ + setEmail(e.target.value)} + className="input-field" + placeholder="vous@exemple.com" + required + autoFocus + />
- - {error && ( -
- {error} -
- )} +
+ + setPassword(e.target.value)} + className="input-field" + placeholder="Au moins 8 caractères" + required + minLength={8} + /> +
-
- - setEmail(e.target.value)} - className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light" - placeholder="vous@exemple.com" - required - /> -
+
+ + setConfirmPassword(e.target.value)} + className="input-field" + placeholder="••••••••" + required + minLength={8} + /> +
-
- - setPassword(e.target.value)} - className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light" - placeholder="••••••••" - required - minLength={8} - /> -
+ + {registerMutation.isPending ? 'Création…' : 'Créer mon compte'} + +
-
- - setConfirmPassword(e.target.value)} - className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light" - placeholder="••••••••" - required - minLength={8} - /> -
- - - - -

- Déjà membre ?{' '} - -

-
-
+

+ Déjà membre ?{' '} + +

+ ); }