mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat: implement Focus Mode (Speed Read) with swipeable card deck
This commit is contained in:
1 parent
bee0d901f7
commit
a512173f05
11 files changed
+667
-5
No files matched your search
Generated
+68
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user