From a512173f053a346f1ea0c19ae34eb4b73cdaa67a Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Thu, 12 Feb 2026 11:56:16 +0100 Subject: [PATCH] feat: implement Focus Mode (Speed Read) with swipeable card deck --- web/package-lock.json | 68 +++++++ web/package.json | 2 + web/src/components/Sidebar.tsx | 23 ++- web/src/components/focus/FocusArticleView.tsx | 100 +++++++++++ web/src/components/focus/FocusCard.tsx | 82 +++++++++ web/src/components/focus/FocusCardStack.tsx | 167 ++++++++++++++++++ web/src/components/focus/FocusEmptyState.tsx | 30 ++++ web/src/index.css | 57 ++++++ web/src/layouts/RootLayout.tsx | 15 +- web/src/pages/DashboardPage.tsx | 38 +++- web/src/pages/FocusPage.tsx | 90 ++++++++++ 11 files changed, 667 insertions(+), 5 deletions(-) create mode 100644 web/src/components/focus/FocusArticleView.tsx create mode 100644 web/src/components/focus/FocusCard.tsx create mode 100644 web/src/components/focus/FocusCardStack.tsx create mode 100644 web/src/components/focus/FocusEmptyState.tsx create mode 100644 web/src/pages/FocusPage.tsx diff --git a/web/package-lock.json b/web/package-lock.json index d4acd06..bda5aa1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "^5.90.20", + "@use-gesture/react": "^10.3.1", + "framer-motion": "^12.34.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", @@ -2043,6 +2045,24 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", @@ -2752,6 +2772,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz", + "integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.34.0", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3322,6 +3369,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.34.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz", + "integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3794,6 +3856,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/web/package.json b/web/package.json index 845eab9..2e1be73 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.20", + "@use-gesture/react": "^10.3.1", + "framer-motion": "^12.34.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index ab8f3e0..a83b7fb 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -7,9 +7,11 @@ import { AddFeedModal } from './AddFeedModal'; interface SidebarProps { onSelectFeed: (feedId: string | null) => void; selectedFeedId: string | null; + onEnterFocus: () => void; + isFocusMode: boolean; } -export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) { +export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMode }: SidebarProps) { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const queryClient = useQueryClient(); @@ -140,6 +142,25 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) { + + {/* Focus Mode Entry */} +
+ +
{feeds?.map((feed) => (
+ + {/* Content Scroll View */} +
+ {/* Hero Image */} + {article.image_url && ( +
+
+
+
+ )} + +
+ {/* Header */} +
+
+ {article.feed_title} + • + {formattedDate} +
+

+ {article.title} +

+
+
+ + {/* Article Body */} +
+ {article.content ? ( +
+ ) : ( +

+ {article.summary || "Contenu non disponible."} +

+ )} +
+ + {/* Actions Footer - In Flow */} +
+ + +
+
+
+
+ + ); +} diff --git a/web/src/components/focus/FocusCard.tsx b/web/src/components/focus/FocusCard.tsx new file mode 100644 index 0000000..a5cdfcd --- /dev/null +++ b/web/src/components/focus/FocusCard.tsx @@ -0,0 +1,82 @@ +import type { Article } from '../../api/articles'; + +interface FocusCardProps { + article: Article; + isTop?: boolean; +} + +export function FocusCard({ article, isTop = false }: FocusCardProps) { + const timeToRead = article.content + ? Math.ceil(article.content.length / 1000) + : 1; + + return ( +
+ {/* Image Section (Top 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'} + + + + {new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', { + 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)}... +

+ )} + + {/* Hint (only on top card) */} + {isTop && ( +
+ Cliquer pour lire • Glisser pour trier +
+ )} +
+
+ ); +} diff --git a/web/src/components/focus/FocusCardStack.tsx b/web/src/components/focus/FocusCardStack.tsx new file mode 100644 index 0000000..f1e4854 --- /dev/null +++ b/web/src/components/focus/FocusCardStack.tsx @@ -0,0 +1,167 @@ +import { useState, useEffect } from 'react'; +import { useDrag } from '@use-gesture/react'; +import { motion, AnimatePresence, useMotionValue, useTransform, useSpring } from 'framer-motion'; +import type { Article } from '../../api/articles'; +import { FocusCard } from './FocusCard'; +import { FocusArticleView } from './FocusArticleView'; + +interface FocusCardStackProps { + articles: Article[]; + onMarkRead: (id: string) => void; + onKeep: (id: string) => void; + onEmpty: () => void; +} + +export function FocusCardStack({ articles, onMarkRead, onKeep, onEmpty }: FocusCardStackProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const [readingArticle, setReadingArticle] = useState
(null); + const [exitX, setExitX] = useState(0); + + // Visible stack size + const visibleArticles = articles.slice(currentIndex, currentIndex + 3); + const topArticle = visibleArticles[0]; + + // Motion values for drag + const x = useMotionValue(0); + const rotate = useTransform(x, [-200, 200], [-10, 10]); + // Opacity not used for card itself in this design, removed to fix lint + + // Background color based on drag direction + const bgOpacityRead = useTransform(x, [0, 150], [0, 1]); + const bgOpacityKeep = useTransform(x, [-150, 0], [1, 0]); + + // Spring physics for smooth return + 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 (readingArticle) return; // Disable drag when reading + + const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50); + + if (!active && trigger) { + // Swipe Validé + const isRight = mx > 0; + setExitX(isRight ? 500 : -500); // Trigger exit animation + + // Logic handled after animation completes via AnimatePresence onExitComplete + // But here we need to trigger state change. + // Better approach: State change triggers exit animation naturally? + // No, libraries like Framer Motion handle exit animations when components are removed from DOM. + // So we just need to advance index. + + if (isRight) { + onMarkRead(topArticle.id); + } else { + onKeep(topArticle.id); + } + + setCurrentIndex(prev => prev + 1); + x.set(0); // Reset for next card + } else { + // Dragging or Snap Back + x.set(active ? mx : 0); + } + }); + + // Check for empty stack + useEffect(() => { + if (currentIndex >= articles.length && articles.length > 0) { + onEmpty(); + } + }, [currentIndex, articles.length, onEmpty]); + + if (!topArticle) return null; + + return ( +
+ {/* Reading View Modal */} + + {readingArticle && ( + setReadingArticle(null)} + onNext={(action) => { + setReadingArticle(null); + if (action === 'read') onMarkRead(readingArticle.id); + else onKeep(readingArticle.id); + setCurrentIndex(prev => prev + 1); + }} + /> + )} + + + {/* Background Indicators */} + + ✓ + + + ⭐ + + + {/* Card Stack */} +
+ + {visibleArticles.map((article, i) => { + const isTop = i === 0; + return ( + isTop && setReadingArticle(article)} + animate={{ scale: 1 - i * 0.05, y: i * 20 }} + exit={{ x: exitX, opacity: 0, scale: 0.9, transition: { duration: 0.2 } }} + > + + + ); + })} + +
+ + {/* Static Action Buttons (for mouse users who prefer clicking) */} +
+ + +
+ + {/* Progress Counter */} +
+
+ {articles.length - currentIndex} Restants +
+
+
+ ); +} diff --git a/web/src/components/focus/FocusEmptyState.tsx b/web/src/components/focus/FocusEmptyState.tsx new file mode 100644 index 0000000..b4978d8 --- /dev/null +++ b/web/src/components/focus/FocusEmptyState.tsx @@ -0,0 +1,30 @@ +interface FocusEmptyStateProps { + onBack: () => void; +} + +export function FocusEmptyState({ onBack }: FocusEmptyStateProps) { + return ( +
+
+
+ 🎉 +
+
+ ✓ +
+
+ +
+

C'est tout pour le moment !

+

Vous êtes à jour sur vos lectures.

+
+ + +
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index 7c0dae6..447fae9 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -136,4 +136,61 @@ .magazine-content li { @apply pl-2 marker:text-nature; } +} + +@layer utilities { + .animate-bounce-slow { + animation: bounce-slow 3s infinite; + } + + .animate-fade-in { + animation: fade-in 0.5s ease-out forwards; + } + + .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); + } + } + + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + + .custom-scrollbar::-webkit-scrollbar { + width: 8px; + } + + .custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } + + .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 93eedf9..42293e3 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -1,19 +1,28 @@ import { useState } from 'react'; import { Sidebar } from '../components/Sidebar'; import { DashboardPage } from '../pages/DashboardPage'; +import { FocusPage } from '../pages/FocusPage'; export function RootLayout() { const [selectedFeedId, setSelectedFeedId] = useState(null); + const [isFocusMode, setIsFocusMode] = useState(false); return (
setIsFocusMode(true)} + isFocusMode={isFocusMode} /> - + {isFocusMode ? ( + setIsFocusMode(false)} /> + ) : ( + setIsFocusMode(true)} + /> + )}
); } diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 3187bec..aca2227 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -11,6 +11,7 @@ import type { Article } from '../api/articles'; interface DashboardPageProps { selectedFeedId: string | null; + onEnterFocus: () => void; } // Helper Hook for Responsive Split @@ -27,7 +28,7 @@ function useIsMobile() { return isMobile; } -export function DashboardPage({ selectedFeedId }: DashboardPageProps) { +export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) { const queryClient = useQueryClient(); const [selectedArticle, setSelectedArticle] = useState
(null); const isMobile = useIsMobile(); @@ -132,6 +133,41 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
+ {/* Focus Mode CTA Widget */} + {!selectedFeedId && articles && articles.length > 0 && ( +
+ {/* decorative circles */} +
+
+ +
+
+ + Nouveauté + +
+

+ Votre session de lecture +

+

+ {articles.filter(a => !a.is_read).length} articles non lus vous attendent. Passez en mode Focus pour une lecture fluide et sans distraction. +

+
+ +
+ +
+
+ )} + {/* Grid */} {articles && articles.length > 0 ? (
diff --git a/web/src/pages/FocusPage.tsx b/web/src/pages/FocusPage.tsx new file mode 100644 index 0000000..a9bc55d --- /dev/null +++ b/web/src/pages/FocusPage.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { articlesApi } from '../api/articles'; +import { FocusCardStack } from '../components/focus/FocusCardStack'; +import { FocusEmptyState } from '../components/focus/FocusEmptyState'; + +interface FocusPageProps { + onExit: () => void; +} + +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 + }); + + // Mutation to mark read + const markReadMutation = useMutation({ + mutationFn: (id: string) => articlesApi.markRead(id), + onSuccess: () => { + // Invalidate main lists to sync up when returning + queryClient.invalidateQueries({ queryKey: ['articles'] }); + queryClient.invalidateQueries({ queryKey: ['feeds'] }); + }, + }); + + const handleMarkRead = (id: string) => { + markReadMutation.mutate(id); + }; + + const handleKeep = (id: string) => { + // Do nothing api-wise, just skip. + // Or strictly speaking, we could have a "skipped" list, but for now just don't mark read. + console.log('Skipped:', id); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + // Filter out locally read? No, we handle that in stack index. + // But if API returns empty: + if (!articles || articles.length === 0) { + return ; + } + + if (isComplete) { + return ; + } + + return ( +
+ {/* Header */} +
+
+ + Mode Focus +
+ +
+ + {/* Main Deck Area */} +
+ setIsComplete(true)} + /> +
+
+ ); +}