Standardize Focus Mode Article View & Fix UI

This commit is contained in:
Michael committed 2026-02-12 13:10:51 +01:00
1 parent 87538901d2
commit 803b51193a
4 files changed
+100 -112

No files matched your search

@@ -1,100 +0,0 @@
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>
);
}
+72 -12
View File
@@ -3,19 +3,29 @@ 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';
import { ReaderView } from '../ReaderView';
import { MobileReaderView } from '../MobileReaderView';
import { useIsMobile } from '../../hooks/useIsMobile';
// The CardStack props don't have onToggleFavorite. We might need to add it or Mock it.
// ReaderView requires onToggleFavorite.
// Let's check FocusCardStackProps. It does NOT have it.
// We should add it to props or just use a dummy one for now if not strictly required by user?
// User said "meme system". Dashboard passes `toggleFavoriteMutation.mutate`.
// FocusPage handles mutations. We should pass `onToggleFavorite` to FocusCardStack.
interface FocusCardStackProps {
articles: Article[];
onMarkRead: (id: string) => void;
onKeep: (id: string) => void;
onToggleFavorite: (id: string) => void;
onEmpty: () => void;
}
export function FocusCardStack({ articles, onMarkRead, onKeep, onEmpty }: FocusCardStackProps) {
export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, onEmpty }: FocusCardStackProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [readingArticle, setReadingArticle] = useState<Article | null>(null);
const [exitX, setExitX] = useState(0);
const isMobile = useIsMobile();
// Visible stack size
const visibleArticles = articles.slice(currentIndex, currentIndex + 3);
@@ -78,16 +88,66 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onEmpty }: FocusC
{/* 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);
}}
/>
isMobile ? (
<MobileReaderView
article={readingArticle}
onClose={() => setReadingArticle(null)}
onToggleFavorite={onToggleFavorite}
onNext={() => {
onMarkRead(readingArticle.id);
setCurrentIndex(prev => prev + 1);
setReadingArticle(null);
}}
onPrev={() => {
onKeep(readingArticle.id);
// MobileReaderView 'Next' usually goes forward. 'Prev' goes back?
// Actually in MobileReaderView context, swipe left = Next, swipe Right = Prev.
// Here 'Prev' in the context of Focus Stack might mean 'Keep' or 'Undo'?
// The user's request is "validation" (swipe right? no left usually implies next)
// Let's map Next -> MarkRead (Next card), Prev -> Keep (Skip/Next card but keep).
// Wait, usually swipe left = next item.
// In Tinter: Left = Dislike (skip), Right = Like (Keep/Read).
// In Focus: Right = Validé (Read), Left = Keep (Skip).
// So 'Next' (Swipe Left) should map to 'Keep'?
// Let's check MobileReaderView implementation.
// onSwipedLeft: () => onNext()
// onSwipedRight: () => onPrev()
// If we want to consistency with FocusCardStack:
// Stack: Swipe Right (>0) = Mark Read. Swipe Left (<0) = Keep.
// MobileReaderView: Swipe Left (Next). Swipe Right (Prev).
// So MobileReaderView 'Next' (Left) should be 'Keep'.
// MobileReaderView 'Prev' (Right) should be 'Mark Read'.
// But 'Prev' name is confusing.
// Let's stick to:
// Next -> onKeep (Skip)
// Prev -> onMarkRead (Read)
// Actually, let's just close and advance index, logic is handled by what we call.
// If 'Next' means 'I'm done, show next', it's 'Mark Read'.
// Let's map:
// onNext (Left Swipe) -> Keep (Skip to next)
// onPrev (Right Swipe) -> Mark Read (Done, to next)
// This matches the card stack directions:
// Card Stack: Left Swipe (mx < 0) -> Keep.
// MobileReaderView: Left Swipe -> onNext.
// So onNext === Keep.
// Card Stack: Right Swipe (mx > 0) -> Mark Read.
// MobileReaderView: Right Swipe -> onPrev.
// So onPrev === Mark Read.
onKeep(readingArticle.id);
setCurrentIndex(prev => prev + 1);
setReadingArticle(null);
}}
/>
) : (
<ReaderView
article={readingArticle}
onClose={() => setReadingArticle(null)}
onToggleFavorite={onToggleFavorite}
/>
)
)}
</AnimatePresence>
+14
View File
@@ -0,0 +1,14 @@
import { useState, useEffect } from 'react';
export 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;
}
+14
View File
@@ -39,10 +39,23 @@ export function FocusPage({ onExit }: FocusPageProps) {
},
});
// 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 handleToggleFavorite = (id: string) => {
toggleFavoriteMutation.mutate(id);
};
const handleKeep = (id: string) => {
// Do nothing api-wise, just skip.
console.log('Skipped:', id);
@@ -96,6 +109,7 @@ export function FocusPage({ onExit }: FocusPageProps) {
articles={deck}
onMarkRead={handleMarkRead}
onKeep={handleKeep}
onToggleFavorite={handleToggleFavorite}
onEmpty={() => setIsComplete(true)}
/>
</main>