mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
Standardize Focus Mode Article View & Fix UI
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user