mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-16 19:57:08 +02:00
feat: implement Deep Forest dark mode, swipe gestures, PWA support, and PostgreSQL Full-Text Search
This commit is contained in:
1 parent
3e468d8885
commit
7e802df2a7
18 files changed
+4800
-128
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import type { Article } from '../api/articles';
|
||||
|
||||
interface ArticleCardProps {
|
||||
@@ -8,108 +10,173 @@ interface ArticleCardProps {
|
||||
}
|
||||
|
||||
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||
const timeToRead = Math.ceil((article.content?.length || 500) / 1000) + 1;
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const [isSwiping, setIsSwiping] = useState(false);
|
||||
|
||||
const handlers = useSwipeable({
|
||||
onSwiping: (event) => {
|
||||
const offset = event.deltaX;
|
||||
// Limit swipe to 80px
|
||||
if (Math.abs(offset) < 100) {
|
||||
setSwipeOffset(offset);
|
||||
setIsSwiping(true);
|
||||
}
|
||||
},
|
||||
onSwipedRight: () => {
|
||||
if (swipeOffset > 50) {
|
||||
onToggleRead(article.id, !article.is_read);
|
||||
}
|
||||
resetSwipe();
|
||||
},
|
||||
onSwipedLeft: () => {
|
||||
if (swipeOffset < -50) {
|
||||
onToggleFavorite(article.id);
|
||||
}
|
||||
resetSwipe();
|
||||
},
|
||||
onSwiped: () => resetSwipe(),
|
||||
trackMouse: true,
|
||||
preventScrollOnSwipe: true,
|
||||
delta: 10,
|
||||
});
|
||||
|
||||
const resetSwipe = () => {
|
||||
setSwipeOffset(0);
|
||||
setIsSwiping(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="magazine-card group cursor-pointer flex flex-col h-full relative overflow-visible shadow-xl shadow-paper-muted/20 hover:shadow-2xl hover:shadow-nature/20 transition-all duration-500 rounded-xl border border-transparent hover:border-nature/10" onClick={() => onClick(article)}>
|
||||
|
||||
{/* Unread Ribbon - Polished Wrapping Effect */}
|
||||
{!article.is_read && (
|
||||
<div className="absolute -top-2 -right-2 z-50 w-[100px] h-[100px] overflow-hidden pointer-events-none">
|
||||
{/* The Ribbon Itself */}
|
||||
<div className="absolute top-[22px] -right-[35px] w-[140px] bg-nature text-white text-[10px] font-extrabold uppercase tracking-widest py-1.5 text-center transform rotate-45 shadow-lg shadow-black/20 border-b border-white/10 flex items-center justify-center opacity-100 z-50">
|
||||
Nouveau
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
{/* Action Layers behind the card */}
|
||||
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none">
|
||||
{/* Left side swipe (Mark as Read) */}
|
||||
<div className={`flex items-center space-x-2 transition-opacity duration-300 ${swipeOffset > 10 ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className="bg-nature text-white p-2 rounded-full shadow-lg">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Darker Folds for "Wrapping" Effect (Simulated behind the card) */}
|
||||
<div className="absolute top-0 left-[22px] w-3 h-3 bg-green-900 rotate-45 transform -translate-x-1/2 -translate-y-1/2 z-[-1]"></div>
|
||||
<div className="absolute bottom-[22px] right-0 w-3 h-3 bg-green-900 rotate-45 transform translate-x-1/2 translate-y-1/2 z-[-1]"></div>
|
||||
<span className="text-nature font-black text-[10px] uppercase tracking-widest">
|
||||
{article.is_read ? 'Marquer non lu' : 'Marquer lu'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!article.is_read && (
|
||||
/* External fold bits to cover the corner if needed for perfection */
|
||||
<>
|
||||
<div className="absolute -top-[5px] right-[62px] w-2 h-2 bg-green-800 rotate-45 z-40 rounded-sm"></div>
|
||||
<div className="absolute top-[62px] -right-[5px] w-2 h-2 bg-green-800 rotate-45 z-40 rounded-sm"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image Placeholder with Gradient Overlay */}
|
||||
<div className="aspect-[16/10] overflow-hidden rounded-t-xl rounded-b-none bg-carbon-light relative mb-6">
|
||||
|
||||
|
||||
{/* Visual placeholder or real image */}
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
|
||||
style={{
|
||||
backgroundImage: `url(${article.image_url || 'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800'})`,
|
||||
backgroundColor: '#f0f0f0'
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Meta on top of image */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-20 flex justify-between items-end">
|
||||
<span className="bg-white px-2 py-1 rounded shadow-sm border border-nature/5 text-[10px] text-nature font-black uppercase tracking-[.25em] drop-shadow-sm">
|
||||
{article.feed_title || 'Journal'}
|
||||
</span>
|
||||
<span className="bg-white px-2 py-1 rounded shadow-sm border border-nature/5 text-[10px] text-nature/80 font-black uppercase tracking-widest px-2 py-1 rounded shadow-sm border border-nature/5 drop-shadow-sm">
|
||||
{timeToRead} min
|
||||
</span>
|
||||
{/* Right side swipe (Favorite) */}
|
||||
<div className={`flex items-center space-x-2 transition-opacity duration-300 ${swipeOffset < -10 ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<span className="text-earth font-black text-[10px] uppercase tracking-widest">Favori</span>
|
||||
<div className="bg-earth text-white p-2 rounded-full shadow-lg">
|
||||
<svg className="w-6 h-6" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col px-1 gap-4">
|
||||
<h2 className={`text-xl font-serif transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-black'}`}
|
||||
style={article.is_read ? {} : { textShadow: '0 1px 1px rgba(0,0,0,0.05)' }}
|
||||
>
|
||||
{article.title}
|
||||
</h2>
|
||||
{/* Main Interactive Card */}
|
||||
<article
|
||||
{...handlers}
|
||||
className={`magazine-card group cursor-pointer flex flex-col h-full relative z-10 bg-carbon overflow-visible shadow-xl shadow-paper-muted/20 hover:shadow-2xl hover:shadow-nature/20 rounded-xl border border-transparent hover:border-nature/10 transition-transform ${isSwiping ? '' : 'duration-500 ease-out'}`}
|
||||
style={{
|
||||
transform: `translateX(${swipeOffset}px)`
|
||||
}}
|
||||
onClick={() => !isSwiping && Math.abs(swipeOffset) < 5 && onClick(article)}
|
||||
>
|
||||
|
||||
{article.summary && (
|
||||
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-nature/0 group-hover:opacity-100 transition-opacity">
|
||||
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}...
|
||||
</p>
|
||||
{/* Unread Ribbon - Polished Wrapping Effect */}
|
||||
{!article.is_read && (
|
||||
<div className="absolute -top-2 -right-2 z-50 w-[100px] h-[100px] overflow-hidden pointer-events-none">
|
||||
{/* The Ribbon Itself */}
|
||||
<div className="absolute top-[22px] -right-[35px] w-[140px] bg-nature text-white text-[10px] font-extrabold uppercase tracking-widest py-1.5 text-center transform rotate-45 shadow-lg shadow-black/20 border-b border-white/10 flex items-center justify-center opacity-100 z-50">
|
||||
Nouveau
|
||||
</div>
|
||||
|
||||
{/* Darker Folds for "Wrapping" Effect (Simulated behind the card) */}
|
||||
<div className="absolute top-0 left-[22px] w-3 h-3 bg-green-900 rotate-45 transform -translate-x-1/2 -translate-y-1/2 z-[-1]"></div>
|
||||
<div className="absolute bottom-[22px] right-0 w-3 h-3 bg-green-900 rotate-45 transform translate-x-1/2 translate-y-1/2 z-[-1]"></div>
|
||||
</div>
|
||||
)}
|
||||
{!article.is_read && (
|
||||
/* External fold bits to cover the corner if needed for perfection */
|
||||
<>
|
||||
<div className="absolute -top-[5px] right-[62px] w-2 h-2 bg-green-800 rotate-45 z-40 rounded-sm"></div>
|
||||
<div className="absolute top-[62px] -right-[5px] w-2 h-2 bg-green-800 rotate-45 z-40 rounded-sm"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-6 pb-4 border-t border-white/5">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleRead(article.id, !article.is_read);
|
||||
}}
|
||||
className={`p-1.5 rounded-full transition-all duration-300 border ${article.is_read ? 'text-nature/40 border-nature/10' : 'text-nature bg-nature/5 border-paper-white/40 hover:bg-nature/20 hover:border-nature'}`}
|
||||
title={article.is_read ? "Marquer comme non lu" : "Marquer comme lu"}
|
||||
>
|
||||
<svg className="w-4 h-4" fill={article.is_read ? "none" : "currentColor"} viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(article.id);
|
||||
}}
|
||||
className={`p-1.5 rounded-full transition-all duration-300 ${article.is_favorite ? 'text-nature nature-glow' : 'text-paper-muted hover:text-nature hover:bg-nature/10'}`}
|
||||
title="Ajouter aux favoris"
|
||||
>
|
||||
<svg className="w-4 h-4" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Image Placeholder with Gradient Overlay */}
|
||||
<div className="aspect-[16/10] overflow-hidden rounded-t-xl rounded-b-none bg-carbon-light relative mb-6">
|
||||
|
||||
<span className="text-[10px] text-paper-muted/60 font-medium tracking-wide">
|
||||
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* Visual placeholder or real image */}
|
||||
<div
|
||||
className="w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
|
||||
style={{
|
||||
backgroundImage: `url(${article.image_url || 'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800'})`,
|
||||
backgroundColor: '#f0f0f0'
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Meta on top of image */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-20 flex justify-between items-end">
|
||||
<span className="bg-white px-2 py-1 rounded shadow-sm border border-nature/5 text-[10px] text-nature font-black uppercase tracking-[.25em] drop-shadow-sm">
|
||||
{article.feed_title || 'Journal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col px-1 gap-4">
|
||||
<h2 className={`text-xl font-serif transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-black'}`}
|
||||
style={article.is_read ? {} : { textShadow: '0 1px 1px rgba(0,0,0,0.05)' }}
|
||||
>
|
||||
{article.title}
|
||||
</h2>
|
||||
|
||||
{article.summary && (
|
||||
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-nature/0 group-hover:opacity-100 transition-opacity">
|
||||
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-6 pb-4 border-t border-white/5">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleRead(article.id, !article.is_read);
|
||||
}}
|
||||
className={`p-1.5 rounded-full transition-all duration-300 border ${article.is_read ? 'text-nature/40 border-nature/10' : 'text-nature bg-nature/5 border-paper-white/40 hover:bg-nature/20 hover:border-nature'}`}
|
||||
title={article.is_read ? "Marquer comme non lu" : "Marquer comme lu"}
|
||||
>
|
||||
<svg className="w-4 h-4" fill={article.is_read ? "none" : "currentColor"} viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(article.id);
|
||||
}}
|
||||
className={`p-1.5 rounded-full transition-all duration-300 ${article.is_favorite ? 'text-nature nature-glow' : 'text-paper-muted hover:text-nature hover:bg-nature/10'}`}
|
||||
title="Ajouter aux favoris"
|
||||
>
|
||||
<svg className="w-4 h-4" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-paper-muted/60 font-medium tracking-wide">
|
||||
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user