feat: implement Focus Mode (Speed Read) with swipeable card deck

This commit is contained in:
Michael committed 2026-02-12 11:56:16 +01:00
1 parent bee0d901f7
commit a512173f05
11 files changed
+667 -5

No files matched your search

+68
View File
@@ -9,6 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"@use-gesture/react": "^10.3.1",
"framer-motion": "^12.34.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
@@ -2043,6 +2045,24 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
"license": "MIT"
},
"node_modules/@use-gesture/react": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
"license": "MIT",
"dependencies": {
"@use-gesture/core": "10.3.1"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.1.3", "version": "5.1.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
@@ -2752,6 +2772,33 @@
"url": "https://github.com/sponsors/rawify" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3322,6 +3369,21 @@
"node": "*" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3794,6 +3856,12 @@
"typescript": ">=4.8.4" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+2
View File
@@ -11,6 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"@use-gesture/react": "^10.3.1",
"framer-motion": "^12.34.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
+22 -1
View File
@@ -7,9 +7,11 @@ import { AddFeedModal } from './AddFeedModal';
interface SidebarProps { interface SidebarProps {
onSelectFeed: (feedId: string | null) => void; onSelectFeed: (feedId: string | null) => void;
selectedFeedId: string | null; 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 [isAddModalOpen, setIsAddModalOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -140,6 +142,25 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
</span> </span>
</button> </button>
</div> </div>
{/* Focus Mode Entry */}
<div className="relative group/item mb-8">
<button
onClick={onEnterFocus}
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-lg transition-all duration-300 text-left ${isFocusMode
? 'bg-nature text-white shadow-lg shadow-nature/20'
: 'bg-nature/5 text-nature hover:bg-nature hover:text-white border border-nature/20'
}`}
>
<span className="text-sm font-bold truncate flex items-center uppercase tracking-wider text-[11px]">
<svg className="w-4 h-4 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Mode Focus
</span>
{/* You could add a badge here if we had global unread count available in sidebar props or context */}
</button>
</div>
{feeds?.map((feed) => ( {feeds?.map((feed) => (
<div key={feed.id} className="relative group/item"> <div key={feed.id} className="relative group/item">
<button <button
@@ -0,0 +1,100 @@
import { motion } from 'framer-motion';
import type { Article } from '../../api/articles';
interface FocusArticleViewProps {
article: Article;
onClose: () => void;
onNext: (action: 'read' | 'keep') => void;
}
export function FocusArticleView({ article, onClose, onNext }: FocusArticleViewProps) {
// Format content for readability
const formattedDate = new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6 bg-paper-white/90 backdrop-blur-xl"
>
<div className="w-full max-w-3xl h-full bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col relative border border-gray-100">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-50 w-10 h-10 flex items-center justify-center rounded-full bg-white/80 backdrop-blur hover:bg-white text-paper-white shadow-sm transition-all hover:scale-110"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Content Scroll View */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Hero Image */}
{article.image_url && (
<div className="h-64 sm:h-80 w-full relative">
<div
className="w-full h-full bg-cover bg-center"
style={{ backgroundImage: `url(${article.image_url})` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-black/30" />
</div>
)}
<div className="px-8 py-10 sm:px-12 max-w-2xl mx-auto">
{/* Header */}
<header className="mb-8 text-center">
<div className="flex items-center justify-center space-x-2 text-nature mb-4 uppercase tracking-widest text-xs font-bold">
<span>{article.feed_title}</span>
<span>•</span>
<span>{formattedDate}</span>
</div>
<h1 className="text-3xl sm:text-4xl font-serif font-bold text-paper-white leading-tight mb-6">
{article.title}
</h1>
<div className="w-16 h-1 bg-nature/20 mx-auto rounded-full" />
</header>
{/* Article Body */}
<article className="prose prose-lg prose-stone prose-headings:font-serif prose-headings:font-bold prose-p:font-reading prose-p:leading-loose mx-auto">
{article.content ? (
<div dangerouslySetInnerHTML={{ __html: article.content }} />
) : (
<p className="text-paper-muted italic text-center">
{article.summary || "Contenu non disponible."}
</p>
)}
</article>
{/* Actions Footer - In Flow */}
<div className="mt-16 pt-8 border-t border-gray-100 flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={() => onNext('keep')}
className="px-8 py-4 rounded-xl border border-blue-200 bg-blue-50 text-blue-700 font-bold hover:bg-blue-100 transition-colors flex items-center justify-center gap-2 group"
>
<span className="transform group-hover:scale-110 transition-transform">⭐</span>
Garder pour plus tard
</button>
<button
onClick={() => onNext('read')}
className="px-8 py-4 rounded-xl bg-nature text-white font-bold shadow-lg shadow-nature/20 hover:bg-nature-light hover:shadow-xl hover:-translate-y-1 transition-all flex items-center justify-center gap-2 group"
>
<svg className="w-5 h-5 transform group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
J'ai fini cet article
</button>
</div>
</div>
</div>
</div>
</motion.div>
);
}
+82
View File
@@ -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 (
<article className="w-full h-full bg-white rounded-3xl overflow-hidden shadow-2xl flex flex-col relative select-none cursor-grab active:cursor-grabbing hover:scale-[1.01] transition-transform duration-300">
{/* Image Section (Top 60%) */}
<div className="h-[60%] relative bg-carbon-light overflow-hidden">
{article.image_url ? (
<div
className="w-full h-full bg-cover bg-center transition-transform duration-700"
style={{ backgroundImage: `url(${article.image_url})` }}
/>
) : (
/* Fallback Gradient if no image */
<div className="w-full h-full bg-gradient-to-br from-nature to-nature-light p-8 flex items-end">
<h1 className="text-3xl font-serif italic text-white/90 leading-tight line-clamp-4 drop-shadow-md">
{article.title}
</h1>
</div>
)}
{/* Overlay Gradient for text readability */}
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/60 to-transparent pointer-events-none" />
{/* Badge: Time to Read */}
<div className="absolute top-4 right-4 bg-black/40 backdrop-blur-md text-white text-[10px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full border border-white/10 shadow-lg">
~{timeToRead} min
</div>
</div>
{/* Content Section (Bottom 40%) */}
<div className="flex-1 p-6 flex flex-col bg-white relative">
{/* Source & Date */}
<div className="flex items-center justify-between mb-3 text-[10px] uppercase tracking-widest font-black text-paper-muted/60">
<span className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-nature"></span>
<span className="text-nature/80">
{article.feed_title || 'Journal'}
</span>
</span>
<span>
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
weekday: 'short',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
{/* Title (if image present, otherwise shown in gradient header) */}
{article.image_url && (
<h2 className="text-xl sm:text-2xl font-serif italic text-paper-white leading-tight mb-3 line-clamp-2">
{article.title}
</h2>
)}
{/* Excerpt */}
{article.summary && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-nature/20">
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 140)}...
</p>
)}
{/* Hint (only on top card) */}
{isTop && (
<div className="mt-auto pt-4 text-center opacity-40 text-[10px] uppercase tracking-widest font-bold text-paper-muted animate-pulse">
Cliquer pour lire • Glisser pour trier
</div>
)}
</div>
</article>
);
}
+167
View File
@@ -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<Article | null>(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 (
<div className="relative w-full h-full flex items-center justify-center overflow-hidden">
{/* Reading View Modal */}
<AnimatePresence>
{readingArticle && (
<FocusArticleView
article={readingArticle}
onClose={() => setReadingArticle(null)}
onNext={(action) => {
setReadingArticle(null);
if (action === 'read') onMarkRead(readingArticle.id);
else onKeep(readingArticle.id);
setCurrentIndex(prev => prev + 1);
}}
/>
)}
</AnimatePresence>
{/* Background Indicators */}
<motion.div style={{ opacity: bgOpacityRead }} className="absolute inset-0 bg-nature/10 flex items-center justify-end px-20 pointer-events-none z-0">
<span className="text-nature font-black text-9xl transform rotate-12 opacity-40">✓</span>
</motion.div>
<motion.div style={{ opacity: bgOpacityKeep }} className="absolute inset-0 bg-blue-500/10 flex items-center justify-start px-20 pointer-events-none z-0">
<span className="text-blue-500 font-black text-9xl transform -rotate-12 opacity-40">⭐</span>
</motion.div>
{/* Card Stack */}
<div className="relative w-full max-w-lg aspect-[3/4] sm:aspect-[4/5] md:h-[600px] z-10">
<AnimatePresence mode="popLayout">
{visibleArticles.map((article, i) => {
const isTop = i === 0;
return (
<motion.div
key={article.id}
className="absolute inset-0 rounded-3xl shadow-2xl origin-bottom"
style={{
scale: isTop ? 1 : 1 - i * 0.05,
y: isTop ? xSpring : i * 20, // Only top card moves with drag
rotate: isTop ? rotateSpring : 0,
zIndex: 100 - i,
x: isTop ? xSpring : 0, // Should use the spring value!
cursor: isTop ? 'grab' : 'default',
}}
{...(isTop ? (bind() as any) : {})}
onClick={() => isTop && setReadingArticle(article)}
animate={{ scale: 1 - i * 0.05, y: i * 20 }}
exit={{ x: exitX, opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
>
<FocusCard article={article} isTop={isTop} />
</motion.div>
);
})}
</AnimatePresence>
</div>
{/* Static Action Buttons (for mouse users who prefer clicking) */}
<div className="absolute bottom-10 left-0 right-0 flex justify-center gap-8 z-20">
<button
onClick={() => {
onKeep(topArticle.id);
setCurrentIndex(prev => prev + 1);
}}
className="p-4 rounded-full bg-white shadow-lg text-blue-500 hover:bg-blue-50 hover:scale-110 transition-all"
title="Garder (Gauche)"
>
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" transform="rotate(-90 12 12)" />
</svg>
</button>
<button
onClick={() => {
onMarkRead(topArticle.id);
setCurrentIndex(prev => prev + 1);
}}
className="p-4 rounded-full bg-nature shadow-lg text-white hover:bg-nature-light hover:scale-110 transition-all"
title="J'ai lu (Droite)"
>
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
{/* Progress Counter */}
<div className="absolute top-10 left-0 right-0 text-center z-20 pointer-events-none">
<div className="inline-block bg-black/60 backdrop-blur-md px-4 py-2 rounded-full text-white text-[10px] uppercase font-bold tracking-widest">
{articles.length - currentIndex} Restants
</div>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
interface FocusEmptyStateProps {
onBack: () => void;
}
export function FocusEmptyState({ onBack }: FocusEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center h-full text-center space-y-8 animate-fade-in">
<div className="relative">
<div className="w-32 h-32 rounded-full bg-nature/10 flex items-center justify-center animate-bounce-slow">
<span className="text-6xl">🎉</span>
</div>
<div className="absolute -top-2 -right-2 w-10 h-10 bg-nature text-white rounded-full flex items-center justify-center font-bold border-4 border-carbon shadow-lg">
✓
</div>
</div>
<div className="space-y-2">
<h1 className="text-4xl font-serif italic text-paper-white">C'est tout pour le moment !</h1>
<p className="text-paper-muted uppercase tracking-widest text-xs font-bold">Vous êtes à jour sur vos lectures.</p>
</div>
<button
onClick={onBack}
className="px-8 py-3 rounded-full bg-nature text-white font-bold text-sm uppercase tracking-widest hover:bg-nature-light hover:scale-105 transition-all shadow-xl shadow-nature/20"
>
Retour au Dashboard
</button>
</div>
);
}
+57
View File
@@ -136,4 +136,61 @@
.magazine-content li { .magazine-content li {
@apply pl-2 marker:text-nature; @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);
}
} }
+12 -3
View File
@@ -1,19 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { Sidebar } from '../components/Sidebar'; import { Sidebar } from '../components/Sidebar';
import { DashboardPage } from '../pages/DashboardPage'; import { DashboardPage } from '../pages/DashboardPage';
import { FocusPage } from '../pages/FocusPage';
export function RootLayout() { export function RootLayout() {
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null); const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
const [isFocusMode, setIsFocusMode] = useState(false);
return ( return (
<div className="flex h-screen bg-carbon overflow-hidden"> <div className="flex h-screen bg-carbon overflow-hidden">
<Sidebar <Sidebar
onSelectFeed={setSelectedFeedId} onSelectFeed={setSelectedFeedId}
selectedFeedId={selectedFeedId} selectedFeedId={selectedFeedId}
onEnterFocus={() => setIsFocusMode(true)}
isFocusMode={isFocusMode}
/> />
<DashboardPage {isFocusMode ? (
selectedFeedId={selectedFeedId} <FocusPage onExit={() => setIsFocusMode(false)} />
/> ) : (
<DashboardPage
selectedFeedId={selectedFeedId}
onEnterFocus={() => setIsFocusMode(true)}
/>
)}
</div> </div>
); );
} }
+37 -1
View File
@@ -11,6 +11,7 @@ import type { Article } from '../api/articles';
interface DashboardPageProps { interface DashboardPageProps {
selectedFeedId: string | null; selectedFeedId: string | null;
onEnterFocus: () => void;
} }
// Helper Hook for Responsive Split // Helper Hook for Responsive Split
@@ -27,7 +28,7 @@ function useIsMobile() {
return isMobile; return isMobile;
} }
export function DashboardPage({ selectedFeedId }: DashboardPageProps) { export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null); const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -132,6 +133,41 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
</div> </div>
</header> </header>
{/* Focus Mode CTA Widget */}
{!selectedFeedId && articles && articles.length > 0 && (
<div className="mb-16 relative overflow-hidden rounded-2xl bg-gradient-to-r from-nature to-nature-light shadow-2xl items-center flex flex-col md:flex-row p-8 md:p-12 hover:scale-[1.01] transition-transform cursor-pointer group" onClick={onEnterFocus}>
{/* decorative circles */}
<div className="absolute -top-24 -right-24 w-64 h-64 bg-white/10 rounded-full blur-3xl group-hover:bg-white/20 transition-colors pointer-events-none"></div>
<div className="absolute -bottom-24 -left-24 w-64 h-64 bg-black/10 rounded-full blur-3xl pointer-events-none"></div>
<div className="flex-1 z-10 text-center md:text-left mb-6 md:mb-0">
<div className="flex items-center justify-center md:justify-start space-x-2 mb-2">
<span className="bg-white/20 text-white text-[10px] font-bold px-2 py-1 rounded uppercase tracking-widest backdrop-blur-sm">
Nouveauté
</span>
</div>
<h2 className="text-3xl md:text-4xl font-serif italic text-white mb-2 text-shadow-sm">
Votre session de lecture
</h2>
<p className="text-white/80 font-medium text-sm md:text-base max-w-xl">
{articles.filter(a => !a.is_read).length} articles non lus vous attendent. Passez en mode Focus pour une lecture fluide et sans distraction.
</p>
</div>
<div className="z-10">
<button
onClick={(e) => { e.stopPropagation(); onEnterFocus(); }}
className="bg-white text-nature font-bold text-sm uppercase tracking-widest px-8 py-4 rounded-full shadow-lg hover:bg-gray-50 hover:shadow-xl hover:scale-105 transition-all flex items-center"
>
Lancer le Mode Focus
<svg className="w-5 h-5 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</button>
</div>
</div>
)}
{/* Grid */} {/* Grid */}
{articles && articles.length > 0 ? ( {articles && articles.length > 0 ? (
<div className="magazine-grid"> <div className="magazine-grid">
+90
View File
@@ -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 (
<div className="flex items-center justify-center h-full bg-carbon/50 backdrop-blur-xl">
<div className="w-16 h-16 border-4 border-nature border-t-transparent rounded-full animate-spin" />
</div>
);
}
// Filter out locally read? No, we handle that in stack index.
// But if API returns empty:
if (!articles || articles.length === 0) {
return <FocusEmptyState onBack={onExit} />;
}
if (isComplete) {
return <FocusEmptyState onBack={onExit} />;
}
return (
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-3xl flex flex-col">
{/* Header */}
<header className="absolute top-0 left-0 right-0 p-6 flex justify-between items-center z-50">
<div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-xs font-bold uppercase tracking-widest text-paper-white">Mode Focus</span>
</div>
<button
onClick={onExit}
className="w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-all text-paper-white"
title="Quitter le mode Focus"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
{/* Main Deck Area */}
<main className="flex-1 flex items-center justify-center p-4">
<FocusCardStack
articles={articles}
onMarkRead={handleMarkRead}
onKeep={handleKeep}
onEmpty={() => setIsComplete(true)}
/>
</main>
</div>
);
}