refonte complète du WebUI (Éditorial Botanique)

Refonte de toutes les vues en conservant l'identité magazine/vert nature,
avec un système de design cohérent et accessible.

- index.css : tokens sémantiques, échelle radius/ombres, classes réutilisables
  (btn-*, chip, surface-card, magazine-content), :focus-visible,
  prefers-reduced-motion, grain papier affiné
- nouveaux composants AuthShell (auth split-screen) et MobileTopBar
  (navigation mobile, auparavant absente)
- framer-motion sur toutes les vues ; AddFeedModal expose l'import OPML ;
  Sidebar ajoute déconnexion + identité utilisateur
- corrections : text-gold cassé -> nature, arithmétique Date -> getTime(),
  suppression de App.css mort, ProtectedRoute en Tailwind,
  snapshot du deck Focus conforme aux règles React

Frontend : npm run lint + build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-11 13:21:23 +02:00
1 parent 2a8b5664c5
commit 10fdfdeccb
19 files changed
+1336 -1922

No files matched your search

-552
View File
@@ -1,552 +0,0 @@
:root {
--primary: #6366f1;
--primary-hover: #4f46e5;
--background: #0f172a;
--surface: #1e293b;
--surface-hover: #334155;
--text: #f8fafc;
--text-muted: #94a3b8;
--error: #ef4444;
--success: #22c55e;
--border: #334155;
--radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--background);
color: var(--text);
min-height: 100vh;
}
/* Login/Register Container */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: linear-gradient(135deg, var(--background) 0%, #1a1a2e 100%);
}
.login-card {
background: var(--surface);
border-radius: var(--radius);
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.login-card h1 {
font-size: 2rem;
font-weight: 700;
text-align: center;
background: linear-gradient(135deg, var(--primary) 0%, #a855f7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.login-card .subtitle {
text-align: center;
color: var(--text-muted);
margin-bottom: 2rem;
}
/* Form Styles */
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.875rem 1rem;
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 1rem;
transition: all 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.form-group input::placeholder {
color: var(--text-muted);
}
button[type="submit"] {
width: 100%;
padding: 0.875rem;
background: var(--primary);
border: none;
border-radius: 8px;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 0.5rem;
}
button[type="submit"]:hover:not(:disabled) {
background: var(--primary-hover);
transform: translateY(-1px);
}
button[type="submit"]:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
color: var(--error);
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.register-link {
text-align: center;
margin-top: 1.5rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.register-link a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.register-link a:hover {
text-decoration: underline;
}
/* Dashboard Layout */
.dashboard-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 280px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-nav {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
.nav-item {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-radius: 8px;
color: var(--text-muted);
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 0.25rem;
text-align: left;
}
.nav-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.nav-item.active {
background: rgba(99, 102, 241, 0.1);
color: var(--primary);
font-weight: 500;
}
.nav-item .icon {
font-size: 1.125rem;
}
.nav-item .label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-item .badge {
background: var(--primary);
color: white;
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 10px;
font-weight: 600;
}
.sidebar-section {
margin-top: 2rem;
}
.sidebar-section h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 0 1rem;
margin-bottom: 0.75rem;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border);
}
.add-feed-btn {
width: 100%;
padding: 0.75rem;
background: transparent;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.add-feed-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.dashboard-header {
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.header-actions {
display: flex;
align-items: center;
gap: 1.5rem;
}
.mark-all-read-btn {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
}
.mark-all-read-btn:hover:not(:disabled) {
background: var(--primary);
color: white;
}
.mark-all-read-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.articles-main {
flex: 1;
overflow-y: auto;
padding: 2rem;
}
.articles-grid {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Article Card */
.article-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.article-card:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
}
.article-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.feed-title {
font-weight: 600;
color: var(--primary);
}
.article-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.75rem;
line-height: 1.4;
}
.article-excerpt {
color: var(--text-muted);
font-size: 0.9375rem;
line-height: 1.6;
margin-bottom: 1.25rem;
}
.article-footer {
display: flex;
gap: 1rem;
}
.action-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 0.375rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 1rem;
}
.action-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.action-btn.active {
color: #fbbf24;
border-color: #fbbf24;
}
/* Reader View Overlay */
.reader-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: flex-end;
z-index: 1000;
backdrop-filter: blur(4px);
}
.reader-container {
width: 100%;
max-width: 800px;
background: var(--background);
height: 100%;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.reader-header {
padding: 1rem 2rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--surface);
}
.close-btn {
background: transparent;
border: none;
color: var(--text);
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.reader-actions {
display: flex;
gap: 1rem;
}
.reader-content {
flex: 1;
overflow-y: auto;
padding: 3rem 4rem;
line-height: 1.6;
}
.reader-content .meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1.5rem;
}
.reader-content .title {
font-size: 2.5rem;
font-weight: 800;
margin-bottom: 1rem;
line-height: 1.2;
}
.reader-content .author {
color: var(--text-muted);
font-style: italic;
margin-bottom: 2rem;
}
.content-body {
font-size: 1.125rem;
color: #e2e8f0;
}
.content-body p {
margin-bottom: 1.5rem;
}
.content-body img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1.5rem 0;
}
.content-body a {
color: var(--primary);
text-decoration: underline;
}
.content-body h1,
.content-body h2,
.content-body h3 {
margin-top: 2rem;
margin-bottom: 1rem;
}
/* Loading/Empty States */
.loading-state {
display: flex;
justify-content: center;
padding: 4rem;
}
/* Mobile Adjustments */
@media (max-width: 768px) {
.sidebar {
display: none;
}
.reader-content {
padding: 2rem 1.5rem;
}
.reader-content .title {
font-size: 1.75rem;
}
}
/* Loading */
.loading-screen {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Mobile First */
@media (max-width: 480px) {
.login-card {
padding: 1.5rem;
}
.login-card h1 {
font-size: 1.5rem;
}
}
+100 -54
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { feedsApi } from '../api/feeds';
interface AddFeedModalProps {
@@ -9,10 +10,11 @@ interface AddFeedModalProps {
export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
const [url, setUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const addFeedMutation = useMutation({
mutationFn: (url: string) => feedsApi.add({ url }),
mutationFn: (u: string) => feedsApi.add({ url: u }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
setUrl('');
@@ -20,68 +22,112 @@ export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
},
});
if (!isOpen) return null;
const importMutation = useMutation({
mutationFn: (file: File) => feedsApi.importOPML(file),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
queryClient.invalidateQueries({ queryKey: ['articles'] });
window.alert(`${res.imported} flux importé(s), ${res.skipped} ignoré(s).`);
onClose();
},
});
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addFeedMutation.mutate(url);
};
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-carbon-dark/80 backdrop-blur-sm animate-in fade-in duration-300" onClick={onClose}>
<div
className="w-full max-w-md paper-panel p-8 rounded-2xl shadow-2xl border border-nature/10 animate-in zoom-in duration-300"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-serif text-nature italic">Nouveau Flux</h2>
<button onClick={onClose} className="text-paper-muted hover:text-white transition-colors">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="add-feed-title"
>
<motion.div
className="w-full max-w-md surface-card p-8"
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-2">
<h2 id="add-feed-title" className="text-2xl font-serif italic text-nature">Nouveau flux</h2>
<button onClick={onClose} className="icon-btn" aria-label="Fermer">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-paper-muted text-xs mb-6 leading-relaxed">
Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera automatiquement le titre et le contenu.
</p>
<p className="text-paper-muted text-sm mb-6 leading-relaxed font-reading">
Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et le contenu automatiquement.
</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="block text-[10px] uppercase tracking-[0.2em] font-bold text-paper-muted">URL du Flux</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input-field w-full"
placeholder="https://exemple.com/feed"
required
autoFocus
/>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label htmlFor="feed-url" className="block eyebrow text-paper-muted">URL du flux</label>
<input
id="feed-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="input-field"
placeholder="https://exemple.com/feed"
required
autoFocus
/>
</div>
{addFeedMutation.isError && (
<p className="text-red-400 text-xs italic">Échec de l'ajout. Vérifiez l'URL du flux.</p>
)}
{addFeedMutation.isError && (
<p className="text-danger text-xs" role="alert">Échec de l'ajout. Vérifiez l'URL du flux.</p>
)}
<div className="flex justify-end pt-4 gap-4">
<button
type="button"
onClick={onClose}
className="btn-ghost"
>
Annuler
</button>
<button
type="submit"
disabled={url.length < 5 || addFeedMutation.isPending}
className="btn-primary"
>
{addFeedMutation.isPending ? 'Ajout...' : 'S\'abonner'}
</button>
</div>
</form>
</div>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={onClose} className="btn-ghost">Annuler</button>
<button type="submit" disabled={url.length < 5 || addFeedMutation.isPending} className="btn-primary">
{addFeedMutation.isPending ? 'Ajout…' : "S'abonner"}
</button>
</div>
</form>
{/* OPML import */}
<div className="mt-6 pt-5 border-t border-paper-muted/12">
<input ref={fileInputRef} type="file" accept=".opml,.xml,text/xml" className="hidden" onChange={handleFile} />
<button
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
className="btn-ghost w-full justify-center"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{importMutation.isPending ? 'Import en cours…' : 'Importer un fichier OPML'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+82 -116
View File
@@ -9,6 +9,14 @@ interface ArticleCardProps {
onToggleFavorite: (id: string) => void;
}
const FALLBACK_IMAGE =
'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800';
function plainExcerpt(html: string, max = 130): string {
const text = html.replace(/<[^>]*>?/gm, '').trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
}
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
const [swipeOffset, setSwipeOffset] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
@@ -16,23 +24,17 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
const handlers = useSwipeable({
onSwiping: (event) => {
const offset = event.deltaX;
// Limit swipe to 80px
if (Math.abs(offset) < 100) {
setSwipeOffset(offset);
if (Math.abs(event.deltaX) < 100) {
setSwipeOffset(event.deltaX);
setIsSwiping(true);
}
},
onSwipedRight: () => {
if (swipeOffset > 50) {
onToggleRead(article.id, !article.is_read);
}
if (swipeOffset > 50) onToggleRead(article.id, !article.is_read);
resetSwipe();
},
onSwipedLeft: () => {
if (swipeOffset < -50) {
onToggleFavorite(article.id);
}
if (swipeOffset < -50) onToggleFavorite(article.id);
resetSwipe();
},
onSwiped: () => resetSwipe(),
@@ -49,156 +51,120 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
const handleSummarize = async (e: React.MouseEvent) => {
e.stopPropagation();
if (article.ai_summary || isSummarizing) return;
setIsSummarizing(true);
try {
await articlesApi.summarize(article.id);
// The article will be updated via WebSocket broadcast
} catch (error) {
console.error('Failed to summarize:', error);
alert('Erreur lors de la génération du résumé. Vérifiez votre clé API OpenRouter.');
} finally {
setIsSummarizing(false);
}
};
return (
<div className="relative overflow-visible pb-4">
{/* Action Layers behind the card */}
<div className="relative">
{/* Swipe action hints 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>
<span className="text-nature font-black text-[10px] uppercase tracking-widest">
{article.is_read ? 'Marquer non lu' : 'Marquer lu'}
</span>
</div>
{/* 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>
<span
className={`chip transition-opacity duration-300 ${swipeOffset > 10 ? 'opacity-100' : 'opacity-0'}`}
>
{article.is_read ? 'Marquer non lu' : 'Marquer lu'}
</span>
<span
className={`chip bg-earth/15 text-earth transition-opacity duration-300 ${swipeOffset < -10 ? 'opacity-100' : 'opacity-0'}`}
>
Favori
</span>
</div>
{/* Main Interactive Card */}
<article
{...handlers}
className={`magazine-card group cursor-pointer flex flex-col h-full relative z-10 bg-white dark:bg-carbon overflow-visible rounded-xl border border-carbon-dark/30 dark:border-white/5 transition-all ${isSwiping ? '' : 'duration-500 ease-out'} hover:border-nature/20`}
onClick={() => !isSwiping && Math.abs(swipeOffset) < 5 && onClick(article)}
className={`group relative z-10 cursor-pointer flex flex-col overflow-hidden rounded-2xl
bg-carbon-light border transition-colors
${article.is_read ? 'border-paper-muted/10' : 'border-nature/20'}
hover:border-nature/40`}
style={{
transform: `translateX(${swipeOffset}px)`,
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.1)'
transition: isSwiping ? 'none' : 'transform 0.45s cubic-bezier(0.22,1,0.36,1)',
boxShadow: 'var(--shadow-soft)',
}}
onClick={() => !isSwiping && Math.abs(swipeOffset) < 5 && 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>
{/* 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>
</>
)}
{/* Image Placeholder with Gradient Overlay */}
<div className="aspect-[16/9] overflow-hidden rounded-t-xl rounded-b-none bg-carbon relative mb-4">
{/* Visual placeholder or real image */}
{/* Cover */}
<div className="aspect-[16/9] overflow-hidden relative bg-carbon-dark">
<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>
style={{ backgroundImage: `url(${article.image_url || FALLBACK_IMAGE})` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/45 via-transparent to-transparent" />
{/* 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>
<span className="absolute bottom-3 left-3 chip bg-carbon-light/90 backdrop-blur-sm shadow-sm">
{article.feed_title || 'Journal'}
</span>
{!article.is_read && (
<span
className="absolute top-3 right-3 w-2.5 h-2.5 rounded-full bg-nature ring-4 ring-nature/20"
title="Non lu"
aria-label="Article non lu"
/>
)}
</div>
{/* Content */}
<div className="flex-1 flex flex-col px-5 gap-3">
<h2 className={`text-2xl font-serif leading-snug transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-black'}`}
{/* Body */}
<div className="flex-1 flex flex-col gap-3 px-5 pt-4 pb-3">
<h2
className={`text-2xl font-serif leading-snug transition-colors group-hover:text-nature
${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-bold'}`}
>
{article.title}
</h2>
{article.ai_summary ? (
<div className="flex items-center space-x-2 text-nature/80">
<span className="text-sm px-2 py-0.5 bg-nature/10 rounded-full font-medium flex items-center">
<span className="mr-1">✨</span> Résumé
</span>
</div>
) : article.summary && (
<p className="text-paper-muted/90 text-sm leading-relaxed line-clamp-2 font-reading group-hover:text-paper-white transition-colors">
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 120)}...
<span className="chip self-start">✨ Résumé disponible</span>
) : article.summary ? (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-2 font-reading">
{plainExcerpt(article.summary)}
</p>
)}
) : null}
<div className="mt-auto flex items-center justify-between pt-4 pb-5 border-t border-carbon-dark/20 dark:border-white/5">
<div className="flex space-x-2">
<div className="mt-auto flex items-center justify-between pt-3 border-t border-paper-muted/10">
<div className="flex items-center gap-1">
<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"}
onClick={(e) => { e.stopPropagation(); onToggleRead(article.id, !article.is_read); }}
className={`p-2 rounded-full border transition-all
${article.is_read
? 'text-nature/40 border-transparent hover:bg-nature/5'
: 'text-nature border-nature/20 hover:bg-nature/10'}`}
title={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
aria-label={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">
<svg className="w-4 h-4" fill={article.is_read ? 'none' : 'currentColor'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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"
onClick={(e) => { e.stopPropagation(); onToggleFavorite(article.id); }}
className={`p-2 rounded-full transition-all ${article.is_favorite ? 'text-earth' : 'text-paper-muted hover:text-earth hover:bg-earth/10'}`}
title={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
aria-label={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
aria-pressed={article.is_favorite}
>
<svg className="w-4 h-4" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
<button
onClick={handleSummarize}
disabled={isSummarizing || !!article.ai_summary}
className={`p-1.5 rounded-full transition-all duration-300 flex items-center justify-center ${article.ai_summary
? 'text-nature bg-nature/10 opacity-50 cursor-default'
: isSummarizing
? 'text-nature animate-pulse'
: 'text-paper-muted hover:text-nature hover:bg-nature/10'}`}
title={article.ai_summary ? "Résumé généré" : "Générer un résumé IA (Smart Digest)"}
className={`p-2 rounded-full transition-all ${article.ai_summary ? 'text-nature opacity-40 cursor-default' : 'text-paper-muted hover:text-nature hover:bg-nature/10'}`}
title={article.ai_summary ? 'Résumé déjà généré' : 'Générer un résumé IA'}
aria-label={article.ai_summary ? 'Résumé déjà généré' : 'Générer un résumé IA'}
>
{isSummarizing ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
) : (
@@ -207,14 +173,14 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
</button>
</div>
<span className="text-[10px] text-paper-muted/60 font-medium tracking-wide">
<time className="text-[11px] text-paper-muted/70 font-medium">
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
minute: '2-digit',
})}
</span>
</time>
</div>
</div>
</article>
+78
View File
@@ -0,0 +1,78 @@
import { motion } from 'framer-motion';
interface AuthShellProps {
eyebrow: string;
title: string;
subtitle: string;
children: React.ReactNode;
}
/**
* Split-screen editorial layout shared by Login & Register.
* Left: brand panel (hidden on mobile). Right: the form card.
*/
export function AuthShell({ eyebrow, title, subtitle, children }: AuthShellProps) {
return (
<div className="min-h-screen flex">
{/* Brand panel */}
<aside className="hidden lg:flex w-[45%] relative overflow-hidden bg-nature-dark text-white flex-col justify-between p-14">
<div
className="absolute inset-0 opacity-30 pointer-events-none"
style={{
backgroundImage:
'radial-gradient(circle at 20% 20%, rgba(52,210,123,0.35), transparent 45%), radial-gradient(circle at 80% 80%, rgba(180,91,62,0.25), transparent 45%)',
}}
/>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="relative z-10"
>
<span className="text-nature-light text-5xl font-serif italic">F.</span>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1 }}
className="relative z-10 space-y-6"
>
<h2 className="text-5xl font-serif italic leading-[1.1] text-balance">
Le calme dans le chaos de l'information.
</h2>
<p className="text-white/70 font-reading text-lg max-w-sm leading-relaxed">
Un agrégateur RSS minimaliste pour reprendre le contrôle de votre veille.
</p>
</motion.div>
<p className="relative z-10 text-white/40 text-[10px] uppercase tracking-[0.4em] font-bold">
FlowReader · Édition 2026
</p>
</aside>
{/* Form side */}
<main className="flex-1 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-md"
>
<div className="lg:hidden text-center mb-8">
<span className="text-nature text-4xl font-serif italic">FlowReader</span>
</div>
<div className="surface-card p-8 sm:p-10">
<header className="mb-8">
<p className="eyebrow mb-2">{eyebrow}</p>
<h1 className="text-4xl font-serif italic text-paper-white mb-2">{title}</h1>
<p className="text-paper-muted text-sm">{subtitle}</p>
</header>
{children}
</div>
</motion.div>
</main>
</div>
);
}
+88 -73
View File
@@ -1,6 +1,8 @@
import type { Article } from '../api/articles';
import { useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import { motion } from 'framer-motion';
import { ShareButton } from './ShareButton';
import { type Article, articlesApi } from '../api/articles';
interface MobileReaderViewProps {
article: Article;
@@ -11,117 +13,130 @@ interface MobileReaderViewProps {
}
export function MobileReaderView({ article, onClose, onToggleFavorite, onNext, onPrev }: MobileReaderViewProps) {
// Determine content to show: full content > summary > default message
let displayContent = article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
const [aiSummary, setAiSummary] = useState(article.ai_summary);
const [isSummarizing, setIsSummarizing] = useState(false);
// If we have a hero image, try to remove the first image from the content to avoid duplicates
if (article.image_url) {
displayContent = displayContent.replace(/<img[^>]*>/, '');
}
let displayContent =
article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
if (article.image_url) displayContent = displayContent.replace(/<img[^>]*>/, '');
// Swipe handlers
const handlers = useSwipeable({
onSwipedLeft: () => onNext(),
onSwipedRight: () => onPrev(),
preventScrollOnSwipe: false,
trackMouse: true // Allow testing with mouse in devtools
trackMouse: true,
});
const handleSummarize = async () => {
setIsSummarizing(true);
try {
const res = await articlesApi.summarize(article.id);
setAiSummary(res.summary);
} catch (err) {
console.error('Failed to summarize:', err);
} finally {
setIsSummarizing(false);
}
};
return (
<div
className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-md flex justify-center items-start overflow-y-auto animate-in fade-in duration-500"
<motion.div
className="fixed inset-0 z-50 bg-carbon flex justify-center items-start overflow-y-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={article.title}
>
<div
<motion.div
{...handlers}
className="w-full min-h-screen bg-carbon-light shadow-2xl relative animate-in slide-in-from-bottom duration-500 pb-20"
className="w-full min-h-screen bg-carbon-light relative pb-24"
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 30, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
>
{/* Mobile Header / Nav Indicator Hint */}
<div className="absolute top-0 left-0 right-0 h-1 z-50 bg-gradient-to-r from-transparent via-white/20 to-transparent opacity-50"></div>
{/* Hero Image Section */}
{article.image_url && (
<div className="w-full h-[40vh] relative overflow-hidden">
<img
src={article.image_url}
alt={article.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent"></div>
<div className="w-full h-[38vh] relative overflow-hidden">
<img src={article.image_url} alt="" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent" />
</div>
)}
<div className={`px-6 ${!article.image_url ? 'pt-12' : 'pt-6'}`}>
<div className={`px-6 ${article.image_url ? 'pt-6' : 'pt-14'}`}>
<header className="mb-8">
<div className="flex items-center space-x-2 text-nature text-[10px] uppercase tracking-[0.3em] font-black mb-4">
<span className="bg-nature/10 px-2 py-1 rounded">{article.feed_title}</span>
<span className="opacity-30">•</span>
<span>{article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }) : 'Aujourd\'hui'}</span>
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="chip">{article.feed_title}</span>
<span className="text-paper-muted/40">·</span>
<span className="text-paper-muted text-xs">
{article.published_at
? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
: "Aujourd'hui"}
</span>
</div>
<h1 className="text-3xl font-serif text-paper-white leading-[1.2] italic-nature tracking-tight">
<h1 className="text-3xl font-serif text-paper-white leading-tight tracking-tight text-balance">
{article.title}
</h1>
{aiSummary ? (
<div className="mt-6 bg-nature/5 border-l-4 border-nature p-4 rounded-r-2xl">
<h2 className="eyebrow mb-2 flex items-center gap-2"><span>✨</span> Résumé IA</h2>
<p className="text-paper-white/90 leading-relaxed font-reading italic">{aiSummary}</p>
</div>
) : (
<button onClick={handleSummarize} disabled={isSummarizing} className="btn-secondary mt-6">
<span className={isSummarizing ? 'animate-spin' : ''}>{isSummarizing ? '⏳' : '✨'}</span>
{isSummarizing ? 'Génération…' : 'Générer le résumé'}
</button>
)}
</header>
<div
className="magazine-content text-paper-white/90 text-xl font-reading leading-loose space-y-6 selection:bg-nature/20 break-words overflow-x-hidden mb-12"
className="magazine-content text-lg break-words mb-12"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
{/* Mobile Action Bar */}
<div className="flex items-center justify-between py-6 border-y border-white/5 mb-8">
<div className="flex items-center justify-between py-5 border-y border-paper-muted/12 mb-8">
<ShareButton article={article} />
<button
onClick={() => onToggleFavorite(article.id)}
className={`flex items-center space-x-3 px-4 py-2 rounded-full border transition-all duration-300 ${article.is_favorite
? 'bg-nature text-white border-nature'
: 'border-paper-white/20 text-nature hover:bg-nature/5'
}`}
className={`flex items-center gap-2 px-4 py-2 rounded-full border text-[11px] uppercase tracking-[0.18em] font-bold transition-all ${
article.is_favorite ? 'bg-earth text-white border-earth' : 'border-earth/30 text-earth'
}`}
aria-pressed={article.is_favorite}
>
<svg className="w-5 h-5" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
<span className="text-[10px] uppercase tracking-[0.2em] font-black">
{article.is_favorite ? 'Favori' : 'Favori'}
</span>
{article.is_favorite ? 'Favori' : 'Ajouter'}
</button>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white/5 rounded-full text-paper-muted hover:text-white transition-colors"
>
<svg className="w-5 h-5" 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>
</a>
{article.url && (
<a href={article.url} target="_blank" rel="noopener noreferrer" className="icon-btn" aria-label="Source d'origine">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
)}
</div>
<footer className="text-center pb-24 opacity-60">
<p className="text-[10px] uppercase tracking-widest text-paper-muted animate-pulse">
Swipe pour lire la suite
</p>
<footer className="text-center pb-10 opacity-60">
<p className="eyebrow text-paper-muted animate-pulse">Glissez pour lire la suite</p>
</footer>
</div>
</div>
</motion.div>
{/* Fixed Close Button for Mobile - Top Right */}
<div className="fixed top-6 right-6 z-50 pointer-events-none">
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="w-10 h-10 bg-nature text-carbon border border-white/10 rounded-full flex items-center justify-center active:scale-95 transition-all duration-300 pointer-events-auto hover:scale-110 shadow-lg"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); onClose(); }}
className="fixed top-5 right-5 z-50 w-10 h-10 bg-nature text-white rounded-full flex items-center justify-center active:scale-95 shadow-lg"
aria-label="Fermer"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</motion.div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { feedsApi } from '../api/feeds';
import { AddFeedModal } from './AddFeedModal';
import { ThemeToggle } from './ThemeToggle';
interface MobileTopBarProps {
onSelectFeed: (feedId: string | null) => void;
selectedFeedId: string | null;
onEnterFocus: () => void;
}
/** Compact navigation for small screens (the full Sidebar is hidden on mobile). */
export function MobileTopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: MobileTopBarProps) {
const [isAddOpen, setIsAddOpen] = useState(false);
const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
return (
<>
<header className="md:hidden sticky top-0 z-30 bg-carbon-light/85 backdrop-blur-xl border-b border-paper-muted/12 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<button onClick={() => onSelectFeed(null)} className="text-nature text-xl font-serif italic shrink-0">
FlowReader
</button>
<div className="flex items-center gap-2">
<button onClick={onEnterFocus} className="icon-btn" title="Mode Focus" aria-label="Mode Focus">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
<ThemeToggle />
<button onClick={() => setIsAddOpen(true)} className="icon-btn" title="Ajouter un flux" aria-label="Ajouter un flux">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</div>
{/* Feed selector chips */}
<div className="flex gap-2 mt-3 overflow-x-auto pb-1 -mx-1 px-1 no-scrollbar">
{[
{ id: null as string | null, label: 'Tous' },
{ id: 'favorites' as string | null, label: 'Favoris' },
...(feeds ?? []).map((f) => ({ id: f.id as string | null, label: f.title })),
].map((item) => (
<button
key={item.id ?? 'all'}
onClick={() => onSelectFeed(item.id)}
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
selectedFeedId === item.id
? 'bg-nature text-white'
: 'bg-nature/8 text-paper-muted hover:text-nature'
}`}
>
{item.label}
</button>
))}
</div>
</header>
<AddFeedModal isOpen={isAddOpen} onClose={() => setIsAddOpen(false)} />
</>
);
}
+3 -3
View File
@@ -6,9 +6,9 @@ export function ProtectedRoute() {
if (isLoading) {
return (
<div className="loading-screen">
<div className="spinner"></div>
<p>Chargement...</p>
<div className="min-h-screen flex flex-col items-center justify-center gap-5 bg-carbon">
<div className="w-11 h-11 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
<p className="eyebrow text-paper-muted">Chargement…</p>
</div>
);
}
+92 -97
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { ShareButton } from './ShareButton';
import { type Article, articlesApi } from '../api/articles';
@@ -12,156 +13,150 @@ export function ReaderView({ article, onClose, onToggleFavorite }: ReaderViewPro
const [aiSummary, setAiSummary] = useState(article.ai_summary);
const [isSummarizing, setIsSummarizing] = useState(false);
// Determine content to show: full content > summary > default message
let displayContent = article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
let displayContent =
article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
if (article.image_url) displayContent = displayContent.replace(/<img[^>]*>/, '');
// If we have a hero image, try to remove the first image from the content to avoid duplicates
if (article.image_url) {
// Simple regex to remove the first img tag if it exists at the start of the content
// This is a heuristic and might need refinement, but handles the "duplicate" complaint common in RSS
displayContent = displayContent.replace(/<img[^>]*>/, '');
}
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const handleSummarize = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsSummarizing(true);
try {
const res = await articlesApi.summarize(article.id);
setAiSummary(res.summary);
} catch (err) {
console.error('Failed to summarize:', err);
} finally {
setIsSummarizing(false);
}
};
return (
<div
className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-md flex justify-center items-start overflow-y-auto animate-in fade-in duration-500"
<motion.div
className="fixed inset-0 z-50 bg-carbon/90 backdrop-blur-md flex justify-center items-start overflow-y-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={article.title}
>
<div
className="w-full max-w-4xl bg-carbon-light/95 h-fit min-h-[50vh] my-0 md:my-12 shadow-2xl relative animate-in slide-in-from-bottom duration-700 md:rounded-3xl border border-white/5 backdrop-blur-sm"
<motion.article
className="w-full max-w-3xl bg-carbon-light h-fit min-h-[60vh] my-0 md:my-12 relative md:rounded-3xl border border-paper-muted/12 overflow-hidden"
style={{ boxShadow: 'var(--shadow-float)' }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
>
{/* Close button for desktop */}
{/* Desktop close */}
<button
onClick={onClose}
className="absolute top-8 -right-16 hidden md:flex w-12 h-12 items-center justify-center rounded-full border border-nature/20 text-nature hover:bg-nature hover:text-white hover:scale-110 transition-all duration-300 z-50"
className="absolute top-6 right-6 z-50 hidden md:flex w-11 h-11 items-center justify-center rounded-full bg-carbon-light/80 backdrop-blur border border-nature/20 text-nature hover:bg-nature hover:text-white transition-all"
title="Fermer"
aria-label="Fermer"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Hero Image Section */}
{/* Hero */}
{article.image_url && (
<div className="w-full h-[40vh] md:h-[50vh] relative overflow-hidden rounded-t-3xl md:rounded-t-[calc(1.5rem-1px)]">
<img
src={article.image_url}
alt={article.title}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent"></div>
<div className="w-full h-[38vh] md:h-[44vh] relative overflow-hidden">
<img src={article.image_url} alt="" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent" />
</div>
)}
<div className={`p-8 md:p-20 ${!article.image_url ? 'pt-16' : 'pt-8'}`}>
<div className={`px-6 md:px-16 ${article.image_url ? 'pt-8' : 'pt-16'} pb-16`}>
<header className="mb-10">
<div className="flex items-center space-x-3 text-nature text-[10px] uppercase tracking-[0.4em] font-black mb-8">
<span className="bg-nature/10 px-2 py-1 rounded">{article.feed_title}</span>
<span className="opacity-30">•</span>
<span>{article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Aujourd\'hui'}</span>
<div className="flex flex-wrap items-center gap-3 mb-6">
<span className="chip">{article.feed_title}</span>
<span className="text-paper-muted/40">·</span>
<span className="text-paper-muted text-xs font-medium">
{article.published_at
? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
: "Aujourd'hui"}
</span>
</div>
<h1 className="text-4xl md:text-6xl font-serif text-paper-white leading-[1.15] italic-nature tracking-tight mb-8">
<h1 className="text-4xl md:text-5xl font-serif text-paper-white leading-[1.12] tracking-tight mb-8 text-balance">
{article.title}
</h1>
{/* Smart Digest Section */}
{/* Smart Digest */}
{aiSummary ? (
<div className="bg-nature/5 border-l-4 border-nature p-6 rounded-r-2xl mb-12 animate-fade-in shadow-sm">
<h3 className="text-nature font-black text-[10px] uppercase tracking-[0.2em] mb-4 flex items-center">
<span className="mr-2 text-base">✨</span> Résumé
</h3>
<p className="text-paper-white text-lg leading-relaxed font-reading italic">
{aiSummary}
</p>
<div className="bg-nature/5 border-l-4 border-nature p-6 rounded-r-2xl">
<h2 className="eyebrow mb-3 flex items-center gap-2"><span>✨</span> Résumé IA</h2>
<p className="text-paper-white/90 text-lg leading-relaxed font-reading italic">{aiSummary}</p>
</div>
) : (
<div className="mb-12">
<button
onClick={async (e) => {
e.stopPropagation();
setIsSummarizing(true);
try {
const res = await articlesApi.summarize(article.id);
setAiSummary(res.summary);
} catch (err) {
console.error("Failed to summarize:", err);
} finally {
setIsSummarizing(false);
}
}}
disabled={isSummarizing}
className={`group flex items-center space-x-3 bg-nature/5 hover:bg-nature text-nature hover:text-white border border-nature/20 px-6 py-3 rounded-full transition-all duration-500 ${isSummarizing ? 'opacity-50 cursor-wait' : ''}`}
>
<span className={`text-xl transition-transform ${isSummarizing ? 'animate-spin' : 'group-hover:rotate-12'}`}>
{isSummarizing ? '⏳' : '✨'}
</span>
<span className="text-[10px] uppercase tracking-[0.2em] font-black">
{isSummarizing ? 'Génération en cours...' : 'Générer le résumé'}
</span>
</button>
</div>
<button onClick={handleSummarize} disabled={isSummarizing} className="btn-secondary">
<span className={isSummarizing ? 'animate-spin' : ''}>{isSummarizing ? '⏳' : '✨'}</span>
{isSummarizing ? 'Génération…' : 'Générer le résumé'}
</button>
)}
</header>
<div
className="magazine-content max-w-2xl mx-auto text-paper-white/90 text-xl font-reading leading-loose space-y-8 selection:bg-nature/20 break-words overflow-x-hidden mb-12"
className="magazine-content drop-cap max-w-2xl mx-auto text-lg md:text-xl break-words mb-12"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
<div className="flex items-center justify-between py-8 border-y border-white/5 mb-16">
<div className="flex items-center space-x-6">
<div className="flex items-center justify-between py-6 border-y border-paper-muted/12">
<div className="flex items-center gap-3">
<ShareButton article={article} />
<button
onClick={() => onToggleFavorite(article.id)}
className={`flex items-center space-x-3 px-6 py-2.5 rounded-full border transition-all duration-500 ${article.is_favorite
? 'bg-nature text-white border-paper-white/20 shadow-lg'
: 'border-paper-white/20 text-nature hover:bg-nature/5'
}`}
className={`flex items-center gap-2 px-5 py-2.5 rounded-full border text-[11px] uppercase tracking-[0.18em] font-bold transition-all ${
article.is_favorite ? 'bg-earth text-white border-earth' : 'border-earth/30 text-earth hover:bg-earth/10'
}`}
aria-pressed={article.is_favorite}
>
<svg className="w-4 h-4" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4" fill={article.is_favorite ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
<span className="text-[10px] uppercase tracking-[0.2em] font-black">
{article.is_favorite ? 'Dans vos favoris' : 'Ajouter aux favoris'}
</span>
{article.is_favorite ? 'Favori' : 'Ajouter'}
</button>
</div>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center space-x-2 text-[10px] uppercase tracking-[0.2em] font-black text-paper-muted hover:text-nature transition-colors"
>
<span>Source d'origine</span>
<svg className="w-3 h-3 transform group-hover:translate-x-1 transition-transform" 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>
</a>
{article.url && (
<a href={article.url} target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-2 eyebrow text-paper-muted hover:text-nature transition-colors">
Source
<svg className="w-3 h-3 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
)}
</div>
<footer className="text-center pb-24">
<div className="text-nature text-4xl font-serif mb-6 italic select-none">F.</div>
<p className="text-paper-muted/40 text-[10px] uppercase tracking-[0.4em] font-black">
FlowReader Lux Édition • 2026
</p>
<footer className="text-center pt-12">
<div className="text-nature text-3xl font-serif italic select-none mb-3">F.</div>
<p className="eyebrow text-paper-muted/40">FlowReader · Édition 2026</p>
</footer>
</div>
</div>
</motion.article>
{/* Mobile close button overlay */}
{/* Mobile close */}
<button
onClick={onClose}
className="fixed bottom-8 right-8 md:hidden w-14 h-14 bg-nature text-white rounded-full shadow-2xl flex items-center justify-center scale-90 active:scale-75 transition-transform nature-glow z-50"
className="fixed bottom-8 right-8 md:hidden w-14 h-14 bg-nature text-white rounded-full shadow-2xl flex items-center justify-center active:scale-90 transition-transform z-50"
aria-label="Fermer"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</motion.div>
);
}
+155 -158
View File
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../stores/authStore';
import { authApi } from '../api/auth';
import { feedsApi } from '../api/feeds';
import { articlesApi } from '../api/articles';
import { AddFeedModal } from './AddFeedModal';
@@ -15,19 +17,13 @@ interface SidebarProps {
export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMode }: SidebarProps) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const logoutStore = useAuthStore((s) => s.logout);
const { data: feeds } = useQuery({
queryKey: ['feeds'],
queryFn: () => feedsApi.list(),
});
const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
const markAllReadMutation = useMutation({
mutationFn: () => {
if (selectedFeedId) {
return articlesApi.markAllRead(selectedFeedId);
}
return articlesApi.markAllReadGlobal();
},
mutationFn: () => (selectedFeedId ? articlesApi.markAllRead(selectedFeedId) : articlesApi.markAllReadGlobal()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
queryClient.invalidateQueries({ queryKey: ['articles'] });
@@ -38,190 +34,191 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
mutationFn: (id: string) => feedsApi.delete(id),
onSuccess: (_, deletedId) => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
if (selectedFeedId === deletedId) {
onSelectFeed(null);
}
if (selectedFeedId === deletedId) onSelectFeed(null);
},
});
const updateFeedMutation = useMutation({
mutationFn: ({ id, title }: { id: string; title: string }) => feedsApi.update(id, title),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['feeds'] }),
});
const logoutMutation = useMutation({
mutationFn: () => authApi.logout(),
onSuccess: () => { logoutStore(); window.location.href = '/login'; },
});
const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) {
deleteFeedMutation.mutate(id);
}
if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) deleteFeedMutation.mutate(id);
};
const handleRename = (e: React.MouseEvent, id: string, currentTitle: string) => {
e.stopPropagation();
const newTitle = window.prompt('Renommer le flux :', currentTitle);
if (newTitle && newTitle.trim() !== '' && newTitle !== currentTitle) {
updateFeedMutation.mutate({ id, title: newTitle });
updateFeedMutation.mutate({ id, title: newTitle.trim() });
}
};
const navItem = (active: boolean) =>
`w-full flex items-center justify-between px-4 py-2.5 rounded-xl text-left transition-all duration-300 ${
active
? 'bg-nature/10 text-nature border border-nature/20'
: 'text-paper-muted border border-transparent hover:text-nature hover:bg-nature/5'
}`;
const dot = (active: boolean) =>
`w-1.5 h-1.5 rounded-full bg-nature mr-3 transition-opacity ${active ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100'}`;
return (
<>
<aside className="w-72 hidden md:flex bg-carbon-dark/40 backdrop-blur-xl border-r border-earth/10 h-screen flex-col z-20 transition-colors duration-500">
<div className="p-8 pb-4">
<div className="flex items-center justify-between mb-8">
<h1
className="text-nature text-3xl font-serif italic tracking-tight cursor-pointer hover:opacity-80 transition-opacity"
style={{ textShadow: '1px 1px 2px rgba(100, 100, 100, 0.2)' }}
<aside className="w-72 hidden md:flex bg-carbon-light/60 backdrop-blur-xl border-r border-paper-muted/12 h-screen flex-col z-20">
{/* Brand + actions */}
<div className="p-6 pb-3">
<div className="flex items-center justify-between mb-7">
<button
onClick={() => onSelectFeed(null)}
className="text-nature text-2xl font-serif italic tracking-tight hover:opacity-80 transition-opacity"
>
FlowReader
</h1>
<div className="flex items-center space-x-2">
</button>
<div className="flex items-center gap-2">
<ThemeToggle />
<button
onClick={() => setIsAddModalOpen(true)}
className="w-8 h-8 flex items-center justify-center rounded-full border border-nature/20 text-nature hover:bg-nature/10 hover:text-nature-light active:scale-95 transition-all duration-300 shadow-sm"
title="Ajouter un flux"
>
<span className="text-xl leading-none" style={{ marginBottom: '1px' }}>+</span>
<button onClick={() => setIsAddModalOpen(true)} className="icon-btn" title="Ajouter un flux" aria-label="Ajouter un flux">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</div>
<nav className="space-y-1">
<button
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
className={`w-full group flex items-center justify-center space-x-3 px-4 py-3 rounded-lg transition-all duration-300 border ${markAllReadMutation.isPending
? 'bg-nature/5 border-nature/10 text-nature/50 cursor-wait'
: 'bg-transparent border-nature/20 text-paper-muted hover:text-nature hover:bg-nature/5 hover:border-nature/40'
}`}
title="Tout marquer comme lu"
>
<svg className={`w-4 h-4 ${markAllReadMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
{markAllReadMutation.isPending
? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
}
</svg>
<span className="text-xs uppercase tracking-[0.2em] font-bold">
{markAllReadMutation.isPending ? 'Traitement...' : 'Tout marquer lu'}
<button
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
className="btn-secondary w-full"
title="Tout marquer comme lu"
>
<svg className={`w-4 h-4 ${markAllReadMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
{markAllReadMutation.isPending
? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />}
</svg>
{markAllReadMutation.isPending ? 'Traitement…' : 'Tout marquer lu'}
</button>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
<div className="group/item">
<button onClick={() => onSelectFeed(null)} className={navItem(selectedFeedId === null && !isFocusMode)}>
<span className="text-sm font-medium flex items-center truncate">
<span className={dot(selectedFeedId === null)} />Tous les articles
</span>
</button>
</nav>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
<div>
<h3 className="px-4 text-[10px] uppercase tracking-[0.3em] font-black text-paper-muted/30 mb-4">Bibliothèque</h3>
<div className="space-y-1">
<div className="relative group/item mb-2">
<button
onClick={() => onSelectFeed(null)}
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-lg transition-all duration-300 text-left ${selectedFeedId === null
? 'bg-nature/10 text-nature border border-nature/20'
: 'text-paper-muted hover:text-nature-light hover:bg-nature/5'
}`}
>
<span className="text-sm font-medium truncate flex items-center">
<span className={`w-1.5 h-1.5 rounded-full bg-nature mr-3 transition-opacity ${selectedFeedId === null ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100'}`}></span>
Tous les articles
</span>
</button>
</div>
<div className="relative group/item mb-4">
<button
onClick={() => onSelectFeed('favorites')}
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-lg transition-all duration-300 text-left ${selectedFeedId === 'favorites'
? 'bg-nature/10 text-nature border border-nature/20'
: 'text-paper-muted hover:text-nature-light hover:bg-nature/5'
}`}
>
<span className="text-sm font-medium truncate flex items-center">
<span className={`w-1.5 h-1.5 rounded-full bg-nature mr-3 transition-opacity ${selectedFeedId === 'favorites' ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100'}`}></span>
Mes Favoris
</span>
</button>
</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) => (
<div key={feed.id} className="relative group/item">
<button
onClick={() => onSelectFeed(feed.id)}
className={`w-full flex items-center justify-between px-4 py-2.5 rounded-lg transition-all duration-300 text-left ${selectedFeedId === feed.id
? 'bg-nature/10 text-nature border border-nature/20'
: 'text-paper-muted hover:text-nature-light hover:bg-nature/5'
}`}
>
<span className="text-sm font-medium truncate flex items-center">
<span className={`w-1.5 h-1.5 rounded-full bg-nature mr-3 transition-opacity ${selectedFeedId === feed.id ? 'opacity-100' : 'opacity-0 group-hover/item:opacity-100'}`}></span>
{feed.title}
</span>
<div className="flex items-center space-x-2">
{(feed.unread_count ?? 0) > 0 && (
<span className="bg-nature/20 text-nature text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.2rem] text-center font-bold">
{feed.unread_count}
</span>
)}
<button
onClick={(e) => handleRename(e, feed.id, feed.title)}
className="opacity-0 group-hover/item:opacity-50 hover:!opacity-100 p-1 text-nature transition-all duration-300 transform scale-75 hover:scale-100"
title="Renommer le flux"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={(e) => handleDelete(e, feed.id)}
className="opacity-0 group-hover/item:opacity-50 hover:!opacity-100 p-1 text-red-400 transition-all duration-300 transform scale-75 hover:scale-100"
title="Supprimer le flux"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</button>
</div>
))}
</div>
</div>
</div>
<div className="p-6 border-t border-white/5">
<div className="p-4 rounded-xl bg-nature/5 border border-nature/10">
<p className="text-[10px] text-nature/60 uppercase tracking-widest font-bold mb-1">Mode Zen</p>
<p className="text-[9px] text-paper-muted leading-relaxed italic">"Le silence est le bruit des esprits."</p>
<div className="group/item">
<button onClick={() => onSelectFeed('favorites')} className={navItem(selectedFeedId === 'favorites')}>
<span className="text-sm font-medium flex items-center truncate">
<span className={dot(selectedFeedId === 'favorites')} />Mes favoris
</span>
</button>
</div>
<div className="group/item pb-2">
<button
onClick={onEnterFocus}
className={`w-full flex items-center px-4 py-2.5 rounded-xl text-left transition-all duration-300 ${
isFocusMode
? 'bg-nature text-white shadow-lg shadow-nature/20'
: 'bg-nature/5 text-nature border border-nature/20 hover:bg-nature hover:text-white'
}`}
>
<span className="text-[11px] font-bold uppercase tracking-[0.18em] flex items-center">
<svg className="w-4 h-4 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Mode Focus
</span>
</button>
</div>
<h3 className="px-4 pt-4 pb-2 text-[10px] uppercase tracking-[0.3em] font-bold text-paper-muted/40">Mes flux</h3>
{feeds && feeds.length > 0 ? feeds.map((feed) => (
<div key={feed.id} className="group/item">
<button onClick={() => onSelectFeed(feed.id)} className={navItem(selectedFeedId === feed.id)}>
<span className="text-sm font-medium flex items-center truncate">
<span className={dot(selectedFeedId === feed.id)} />
<span className="truncate">{feed.title}</span>
</span>
<span className="flex items-center gap-1 shrink-0">
{(feed.unread_count ?? 0) > 0 && (
<span className="bg-nature/15 text-nature text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center font-bold">
{feed.unread_count}
</span>
)}
<span
role="button"
tabIndex={0}
onClick={(e) => handleRename(e, feed.id, feed.title)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(e as unknown as React.MouseEvent, feed.id, feed.title); }}
className="opacity-0 group-hover/item:opacity-60 hover:!opacity-100 p-1 text-nature transition-opacity cursor-pointer"
title="Renommer le flux"
aria-label="Renommer le flux"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</span>
<span
role="button"
tabIndex={0}
onClick={(e) => handleDelete(e, feed.id)}
onKeyDown={(e) => { if (e.key === 'Enter') handleDelete(e as unknown as React.MouseEvent, feed.id); }}
className="opacity-0 group-hover/item:opacity-60 hover:!opacity-100 p-1 text-danger transition-opacity cursor-pointer"
title="Supprimer le flux"
aria-label="Supprimer le flux"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</span>
</span>
</button>
</div>
)) : (
<p className="px-4 py-6 text-xs text-paper-muted/50 italic font-reading">
Aucun flux pour l'instant. Ajoutez-en un avec le bouton +.
</p>
)}
</nav>
{/* Footer: user + logout */}
<div className="p-4 border-t border-paper-muted/12">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-bold text-paper-white truncate">{user?.email ?? 'Compte'}</p>
<p className="text-[10px] text-paper-muted uppercase tracking-widest font-bold">
{user?.is_admin ? 'Administrateur' : 'Lecteur'}
</p>
</div>
<button
onClick={() => logoutMutation.mutate()}
className="icon-btn shrink-0"
title="Se déconnecter"
aria-label="Se déconnecter"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</aside>
<AddFeedModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
/>
<AddFeedModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} />
</>
);
}
+35 -21
View File
@@ -1,37 +1,51 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
type Theme = 'light' | 'dark';
function getInitialTheme(): Theme {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ThemeToggle() {
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
const root = window.document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
root.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
const isDark = theme === 'dark';
return (
<button
onClick={toggleTheme}
className="w-8 h-8 flex items-center justify-center rounded-full border border-nature/10 text-nature hover:bg-nature/5 transition-all duration-300"
title={theme === 'light' ? 'Activer le mode sombre' : 'Activer le mode clair'}
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="icon-btn"
role="switch"
aria-checked={isDark}
aria-label={isDark ? 'Activer le mode clair' : 'Activer le mode sombre'}
title={isDark ? 'Activer le mode clair' : 'Activer le mode sombre'}
>
{theme === 'light' ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
<motion.span
key={theme}
initial={{ rotate: -90, opacity: 0, scale: 0.5 }}
animate={{ rotate: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="flex"
>
{isDark ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</motion.span>
</button>
);
}
+23 -42
View File
@@ -6,75 +6,56 @@ interface FocusCardProps {
}
export function FocusCard({ article, isTop = false }: FocusCardProps) {
const timeToRead = article.content
? Math.ceil(article.content.length / 1000)
: 1;
const timeToRead = article.content ? Math.max(1, Math.ceil(article.content.length / 1000)) : 1;
const excerpt = article.summary ? article.summary.replace(/<[^>]*>?/gm, '').slice(0, 150) : '';
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 className="w-full h-full bg-carbon-light rounded-3xl overflow-hidden flex flex-col relative select-none cursor-grab active:cursor-grabbing">
{/* Cover (60%) */}
<div className="h-[58%] relative 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})` }}
/>
<div className="w-full h-full bg-cover bg-center" 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">
<h2 className="text-3xl font-serif italic text-white/95 leading-tight line-clamp-4 drop-shadow">
{article.title}
</h1>
</h2>
</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">
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-black/55 to-transparent pointer-events-none" />
<span 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">
~{timeToRead} min
</div>
</span>
</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>
{/* Body (40%) */}
<div className="flex-1 p-6 flex flex-col">
<div className="flex items-center justify-between mb-3 text-[10px] uppercase tracking-widest font-bold text-paper-muted/70">
<span className="flex items-center gap-2 text-nature">
<span className="w-1.5 h-1.5 rounded-full bg-nature" />
{article.feed_title || 'Journal'}
</span>
<span>
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
weekday: 'short',
hour: '2-digit',
minute: '2-digit'
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>
{excerpt && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">{excerpt}…</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">
Double-cliquer pour lire • Glisser pour trier
</div>
<p className="mt-auto pt-4 text-center text-[10px] uppercase tracking-widest font-bold text-paper-muted/40 animate-pulse">
Double-clic pour lire · Glisser pour trier
</p>
)}
</div>
</article>
+64 -190
View File
@@ -15,10 +15,6 @@ interface FocusCardStackProps {
onEmpty: () => void;
}
// ------------------------------------------------------------------
// Internal Component: SwipeableCard
// Isolates drag state per card to prevent "rebound" on unmount.
// ------------------------------------------------------------------
interface SwipeableCardProps {
article: Article;
index: number;
@@ -29,144 +25,58 @@ interface SwipeableCardProps {
isProcessing: boolean;
}
/** A single draggable card. Drag state is local so unmount never "rebounds" the stack. */
function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef, isProcessing }: SwipeableCardProps) {
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-10, 10]);
const opacityRead = useTransform(x, [0, 150], [0, 1]);
const opacityKeep = useTransform(x, [-150, 0], [1, 0]);
// Background color based on drag direction (Keep/Read indicators)
const bgOpacityRead = useTransform(x, [0, 150], [0, 1]);
const bgOpacityKeep = useTransform(x, [-150, 0], [1, 0]);
// Spring physics for smooth movement
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 (!isTop || isProcessing) return; // Only top card is draggable
if (active && Math.abs(mx) > 5) {
hasDraggedRef.current = true;
}
if (!isTop || isProcessing) return;
if (active && Math.abs(mx) > 5) hasDraggedRef.current = true;
const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50);
if (!active && trigger) {
// Swipe Validated
const isRight = mx > 0;
// Capture nothing here, we just fire action.
// The component unmounts with its current x/xSpring state intact.
// Motion handles the rest via 'exit' prop or just unmounting.
onAction(isRight ? 'read' : 'keep');
onAction(mx > 0 ? 'read' : 'keep');
} else if (!active) {
// Snap Back
x.set(0);
setTimeout(() => { hasDraggedRef.current = false; }, 50);
} else {
// Dragging
x.set(mx);
}
});
// Determine zIndex and scale based on index
const zIndex = 100 - index;
const scale = 1 - index * 0.05;
const yOffset = index * 20;
const yOffset = index * 18;
return (
<motion.div
className="absolute inset-0 rounded-3xl shadow-2xl origin-bottom"
className="absolute inset-0 rounded-3xl origin-bottom"
style={{
scale: isTop ? 1 : scale,
y: isTop ? xSpring : yOffset, // Only top card moves vertically on drag? Actually xSpring affects x/y if we want. Original code used xSpring for y? Let's check.
// Original: y: isTop ? xSpring : i * 20 -> Wait, xSpring is horizontal movement applied to Y?
// Ah, the original code had: y: isTop ? xSpring : i * 20.
// That seems weird if xSpring comes from x (horizontal).
// Maybe it was intended to give a slight vertical lift on drag?
// Or maybe xSpring was actually ySpring in previous code?
// In my reading of previous code: const x = useMotionValue(0); const xSpring = useSpring(x...);
// So y was bound to xSpring. That means swiping right moves the card DOWN?
// That might be a typo in original code or a specific effect.
// Let's stick to X for horizontal.
// Let's look at original again: `y: isTop ? xSpring : i * 20`.
// If I swipe right (x > 0), y increases (card goes down).
// If I swipe left (x < 0), y decreases (card goes up).
// Use 'x' for 'x'. And 'y' for... 0 if top?
// Let's use xSpring for 'x' property.
x: isTop ? xSpring : 0,
rotate: isTop ? rotateSpring : 0,
zIndex: zIndex,
zIndex: 100 - index,
cursor: isTop ? 'grab' : 'default',
boxShadow: 'var(--shadow-float)',
}}
{...(isTop ? (bind() as any) : {})} // Bind gesture only if top
onDoubleClick={() => {
if (isTop && !hasDraggedRef.current) {
onOpen();
}
}}
initial={{ scale: scale, y: yOffset, x: 0 }}
animate={{ scale: scale, y: yOffset, x: 0 }} // Re-center if index changes (but this component is unique by key)
// Correction: When index changes (e.g. going from 1 to 0), we want it to animate to new position.
// But here we are mapping articles.
// If we use 'key={article.id}', the component identity is preserved.
// So if card at index 1 becomes index 0, it animates:
// scale: 0.95 -> 1
// y: 20 -> 0
// x: 0 -> 0
exit={{
// When unmounting (swiped out):
// We want it to keep its current x/rotation roughly, and fade out.
// Since xSpring holds the value, we don't need to force x in exit if we rely on the fact
// that unmounting preserves style for the exit duration?
// Actually, Framer Motion exit prop overrides.
// If we want to freeze, we can use a custom exit.
// But the user complained about rebound because 'x' changed.
// Here, 'x' is LOCAL. And we NEVER reset it if we trigger action.
// So 'xSpring' will stay at ~500 or -500.
// So we can just fade out.
opacity: 0,
scale: 0.9,
transition: { duration: 0.2 }
}}
{...(isTop ? (bind() as Record<string, unknown>) : {})}
onDoubleClick={() => { if (isTop && !hasDraggedRef.current) onOpen(); }}
initial={{ scale, y: yOffset, x: 0 }}
animate={{ scale, y: yOffset, x: 0 }}
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
>
{/* Visual Indicators embedded in card to move with it?
Actually original code had them in background.
If we want them ON the card, we put them here.
If in background, parent handles.
Original code: Indicators were OUTSIDE the card stack, using 'bgOpacityRead' derived from 'x'.
Since 'x' is now local, the parent doesn't know about it.
We should either:
1. Expose 'x' to parent (complex).
2. Put indicators INSIDE the card (but absolute positioned to fill screen? No, clipped).
3. Put indicators INSIDE the card but visual only on the card itself (like Tinder stamps).
Let's emulate the original "Background Indicators" but maybe just on the card itself is better for encapsulation?
Or we can use a callback `onDrag` to update parent state? Performance hit.
Simpler: The user wanted "Swipe card". The indicators were "Background".
If we lose the background indicators, is it bad?
The original code had:
<motion.div style={{ opacity: bgOpacityRead }} ... className="absolute inset-0 ... z-0">
With isolated state, we can't easily drive standard DOM elements outside the component.
But we CAN put the indicators INSIDE the card container?
No, because the card rotates and moves.
Alternative: We only show indicators ON THE CARD (Overlay).
Like a "NOPE" or "LIKE" stamp on the card content.
Let's add that. It's often better UX anyway.
*/}
{/* Overlay Indicators (Like Tinder) */}
<motion.div style={{ opacity: bgOpacityRead }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-nature/20">
<div className="border-4 border-nature text-nature font-black text-4xl uppercase tracking-widest px-4 py-2 rounded-lg transform -rotate-12 bg-white/20 backdrop-blur-sm">
J'M
{/* Tinder-style stamps */}
<motion.div style={{ opacity: opacityRead }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-nature/15">
<div className="border-4 border-nature text-nature font-black text-3xl uppercase tracking-widest px-4 py-2 rounded-lg -rotate-12 bg-carbon-light/40 backdrop-blur-sm">
J'ai lu
</div>
</motion.div>
<motion.div style={{ opacity: bgOpacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-blue-500/20">
<div className="border-4 border-blue-500 text-blue-500 font-black text-4xl uppercase tracking-widest px-4 py-2 rounded-lg transform rotate-12 bg-white/20 backdrop-blur-sm">
<motion.div style={{ opacity: opacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-earth/15">
<div className="border-4 border-earth text-earth font-black text-3xl uppercase tracking-widest px-4 py-2 rounded-lg rotate-12 bg-carbon-light/40 backdrop-blur-sm">
Garder
</div>
</motion.div>
@@ -176,21 +86,13 @@ function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef,
);
}
// ------------------------------------------------------------------
// Main Component
// ------------------------------------------------------------------
export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, onEmpty }: FocusCardStackProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [readingArticle, setReadingArticle] = useState<Article | null>(null);
const isMobile = useIsMobile();
const [isProcessing, setIsProcessing] = useState(false);
// Shared ref just for the double click check logic if needed,
// but actually each card manages its own drag state.
// We pass this ref down so the active card can write to it.
const hasDraggedRef = useRef(false);
// Visible stack size
const visibleArticles = articles.slice(currentIndex, currentIndex + 3);
const topArticle = visibleArticles[0];
@@ -199,38 +101,25 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
setIsProcessing(true);
if (action === 'undo') {
if (currentIndex > 0) {
setCurrentIndex(prev => Math.max(0, prev - 1));
}
setCurrentIndex((prev) => Math.max(0, prev - 1));
} else if (action === 'read') {
onMarkRead(topArticle.id);
setCurrentIndex(prev => prev + 1);
if (topArticle) onMarkRead(topArticle.id);
setCurrentIndex((prev) => prev + 1);
} else {
onKeep(topArticle.id);
setCurrentIndex(prev => prev + 1);
if (topArticle) onKeep(topArticle.id);
setCurrentIndex((prev) => prev + 1);
}
// Lock for animation duration
setTimeout(() => setIsProcessing(false), 500);
setTimeout(() => setIsProcessing(false), 450);
};
// Callbacks for the card components
const handleCardAction = (action: 'read' | 'keep') => {
handleAction(action);
};
// Check for empty stack
useEffect(() => {
if (currentIndex >= articles.length && articles.length > 0) {
onEmpty();
}
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 && (
isMobile ? (
@@ -238,93 +127,78 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
article={readingArticle}
onClose={() => setReadingArticle(null)}
onToggleFavorite={onToggleFavorite}
onNext={() => {
handleAction('read');
setReadingArticle(null);
}}
onPrev={() => {
handleAction('keep');
setReadingArticle(null);
}}
onNext={() => { handleAction('read'); setReadingArticle(null); }}
onPrev={() => { handleAction('keep'); setReadingArticle(null); }}
/>
) : (
<ReaderView
article={readingArticle}
onClose={() => setReadingArticle(null)}
onToggleFavorite={onToggleFavorite}
/>
<ReaderView article={readingArticle} onClose={() => setReadingArticle(null)} onToggleFavorite={onToggleFavorite} />
)
)}
</AnimatePresence>
{/* Note: Background indicators removed in favor of Card Overlays for isolated state cleanliness */}
{/* Card Stack */}
{/* 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) => {
return (
<SwipeableCard
key={article.id}
article={article}
index={i}
isTop={i === 0}
onAction={handleCardAction}
onOpen={() => setReadingArticle(article)}
hasDraggedRef={hasDraggedRef}
isProcessing={isProcessing}
/>
);
})}
{visibleArticles.map((article, i) => (
<SwipeableCard
key={article.id}
article={article}
index={i}
isTop={i === 0}
onAction={handleAction}
onOpen={() => setReadingArticle(article)}
hasDraggedRef={hasDraggedRef}
isProcessing={isProcessing}
/>
))}
</AnimatePresence>
</div>
<div className="absolute bottom-10 left-0 right-0 flex justify-center items-center gap-4 px-4 sm:gap-8 z-20">
{/* Garder */}
{/* Controls */}
<div className="absolute bottom-10 left-0 right-0 flex justify-center items-center gap-4 sm:gap-8 px-4 z-20">
<button
onClick={() => handleAction('keep')}
disabled={isProcessing}
className="p-4 rounded-full bg-white border border-nature/10 text-nature shadow-lg hover:bg-nature hover:text-white hover:border-nature hover:scale-110 transition-all duration-300 disabled:opacity-50 disabled:scale-100"
title="Garder (Gauche)"
className="p-4 rounded-full bg-carbon-light border border-earth/20 text-earth shadow-lg hover:bg-earth hover:text-white hover:scale-110 transition-all disabled:opacity-50 disabled:scale-100"
title="Garder (gauche)" aria-label="Garder"
>
<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 className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
{/* Undo */}
<button
onClick={() => handleAction('undo')}
disabled={currentIndex === 0 || isProcessing}
className={`p-3 rounded-full border shadow-lg transition-all duration-300 ${currentIndex > 0
? 'bg-white border-nature/10 text-nature hover:bg-nature hover:text-white hover:border-nature hover:scale-105 opacity-100 cursor-pointer'
: 'bg-white/5 border-transparent text-transparent opacity-0 pointer-events-none scale-90'
}`}
title="Revenir en arrière"
className={`p-3 rounded-full border shadow-lg transition-all ${
currentIndex > 0
? 'bg-carbon-light border-nature/15 text-nature hover:bg-nature hover:text-white hover:scale-105 opacity-100'
: 'opacity-0 pointer-events-none scale-90'
}`}
title="Annuler" aria-label="Annuler"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
{/* J'ai lu */}
<button
onClick={() => handleAction('read')}
disabled={isProcessing}
className="p-4 rounded-full bg-nature text-white border border-nature shadow-lg shadow-nature/20 hover:bg-nature-light hover:scale-110 hover:shadow-xl transition-all duration-300 disabled:opacity-50 disabled:scale-100"
title="J'ai lu (Droite)"
className="p-4 rounded-full bg-nature text-white border border-nature shadow-lg shadow-nature/20 hover:bg-nature-light hover:scale-110 transition-all disabled:opacity-50 disabled:scale-100"
title="J'ai lu (droite)" aria-label="J'ai lu"
>
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
{/* Progress Counter */}
{/* 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>
<span className="inline-block bg-nature-dark/70 backdrop-blur-md px-4 py-2 rounded-full text-white text-[10px] uppercase font-bold tracking-widest">
{articles.length - currentIndex} restants
</span>
</div>
</div>
);
+14 -12
View File
@@ -1,30 +1,32 @@
import { motion } from 'framer-motion';
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">
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className="flex flex-col items-center justify-center h-full text-center space-y-8 px-6"
>
<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 className="w-28 h-28 rounded-full bg-nature/10 flex items-center justify-center animate-bounce-slow">
<span className="text-5xl">🌿</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 className="absolute -top-1 -right-1 w-9 h-9 bg-nature text-white rounded-full flex items-center justify-center font-bold border-4 border-carbon">
✓
</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>
<p className="eyebrow text-paper-muted">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 border border-nature shadow-lg shadow-nature/20 hover:bg-nature-light hover:scale-105 hover:shadow-xl transition-all duration-300"
>
Retour au Dashboard
</button>
</div>
<button onClick={onBack} className="btn-primary py-3 px-8">Retour au tableau de bord</button>
</motion.div>
);
}
+218 -145
View File
@@ -1,229 +1,302 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Lora:ital,wght@0,400..700;1,400..700&display=swap');
@import "tailwindcss";
/* ============================================================
FlowReader — Design System "Éditorial Botanique"
Light: Organic Warmth · Dark: Deep Forest
============================================================ */
@theme {
--color-carbon: #F9F7F1;
--color-carbon-light: #FFFFFF;
--color-carbon-dark: #E7E5E4;
--color-nature: #166534;
--color-nature-light: #15803d;
--color-nature-dark: #0A0F0B;
/* Deep Forest Base */
--color-earth: #A0522D;
--color-paper-white: #1C1917;
--color-paper-muted: #57534E;
/* Surfaces — warm paper */
--color-carbon: #F7F4EC; /* app background */
--color-carbon-light: #FFFFFF; /* raised surface / cards */
--color-carbon-dark: #E9E4D8; /* borders / sunken */
--font-serif: "Playfair Display", serif;
/* Brand — forest green */
--color-nature: #15643A;
--color-nature-light: #1E8A4F;
--color-nature-dark: #0A2E1B;
/* Accent — terracotta (used sparingly) */
--color-earth: #B45B3E;
--color-earth-light: #C9714F;
/* Ink */
--color-paper-white: #1B1714; /* primary text */
--color-paper-muted: #6B645B; /* secondary text */
/* Semantic */
--color-ring: #1E8A4F;
--color-danger: #C2410C;
/* Typography */
--font-serif: "Playfair Display", Georgia, serif;
--font-sans: "Inter", system-ui, sans-serif;
--font-reading: "Lora", serif;
--font-reading: "Lora", Georgia, serif;
--line-height-magazine: 1.8;
/* Radii scale */
--radius-xs: 0.375rem;
--radius-sm: 0.625rem;
--radius-md: 0.875rem;
--radius-lg: 1.25rem;
--radius-xl: 1.75rem;
--line-height-magazine: 1.85;
/* Shadows */
--shadow-soft: 0 2px 8px -2px rgba(27, 23, 20, 0.08), 0 4px 24px -6px rgba(27, 23, 20, 0.06);
--shadow-lift: 0 12px 32px -12px rgba(27, 23, 20, 0.18);
--shadow-float: 0 24px 60px -20px rgba(10, 46, 27, 0.28);
}
/* Dark Mode Variables */
/* ---------- Dark theme: Deep Forest ---------- */
.dark {
--color-carbon: #0A0F0B;
--color-carbon-light: #141C16;
--color-carbon-dark: #050705;
--color-nature: #22C55E;
--color-nature-light: #4ADE80;
--color-paper-white: #F9F7F1;
--color-paper-muted: #A8A29E;
--color-carbon-light: #12190F;
--color-carbon-dark: #060A06;
--color-nature: #34D27B;
--color-nature-light: #5DE89A;
--color-nature-dark: #0E1C12;
--color-earth: #D98060;
--color-earth-light: #E59A7E;
--color-paper-white: #F4F1E8;
--color-paper-muted: #9A958A;
--color-ring: #34D27B;
--color-danger: #F87045;
--shadow-soft: 0 2px 10px -2px rgba(0, 0, 0, 0.5);
--shadow-lift: 0 16px 40px -16px rgba(0, 0, 0, 0.6);
--shadow-float: 0 28px 70px -24px rgba(0, 0, 0, 0.7);
}
/* ============================================================
Custom utilities
============================================================ */
@utility nature-glow {
box-shadow: 0 4px 20px rgba(22, 101, 52, 0.15);
box-shadow: 0 4px 22px rgba(21, 100, 58, 0.18);
}
.dark .nature-glow {
box-shadow: 0 4px 25px rgba(34, 197, 94, 0.2);
box-shadow: 0 4px 26px rgba(52, 210, 123, 0.22);
}
@utility paper-panel {
background: #FFFFFF;
border: 1px solid #E7E5E4;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
.dark .paper-panel {
background: var(--color-carbon-light);
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
border: 1px solid color-mix(in oklab, var(--color-paper-muted) 16%, transparent);
box-shadow: var(--shadow-soft);
}
@utility text-balance {
text-wrap: balance;
}
/* ============================================================
Base
============================================================ */
@layer base {
*,
*::before,
*::after {
border-color: color-mix(in oklab, var(--color-paper-muted) 18%, transparent);
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
@apply bg-carbon text-paper-white font-sans antialiased selection:bg-nature/20;
@apply bg-carbon text-paper-white font-sans antialiased;
margin: 0;
min-height: 100vh;
border-top: 4px solid var(--color-nature);
/* Editorial Touch */
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.2'/%3E%3C/svg%3E");
border-top: 3px solid var(--color-nature);
background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 8%, transparent), transparent),
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.14'/%3E%3C/svg%3E");
background-blend-mode: multiply;
background-repeat: repeat;
transition: background-color 0.5s ease, color 0.5s ease;
background-repeat: no-repeat, repeat;
transition: background-color 0.4s ease, color 0.4s ease;
}
.dark body {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.05'/%3E%3C/svg%3E");
background-blend-mode: screen;
background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--color-nature) 10%, transparent), transparent),
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
background-blend-mode: screen, screen;
}
h1,
h2,
h3,
h4,
h5,
h6 {
h1, h2, h3, h4, h5, h6 {
@apply font-serif tracking-tight leading-tight text-paper-white;
}
a {
@apply transition-all duration-300 decoration-nature/30 underline-offset-4 hover:decoration-nature;
@apply transition-colors duration-300;
}
::-webkit-scrollbar {
width: 6px;
/* Accessible focus — keyboard only */
:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
::-webkit-scrollbar-track {
background: transparent;
:focus:not(:focus-visible) {
outline: none;
}
/* Scrollbars */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(87, 83, 78, 0.2);
border-radius: 10px;
background: color-mix(in oklab, var(--color-paper-muted) 28%, transparent);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--color-paper-muted) 45%, transparent);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(87, 83, 78, 0.4);
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
/* ============================================================
Components
============================================================ */
@layer components {
.magazine-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 2rem;
padding: 2rem 0;
}
@media (min-width: 768px) {
.magazine-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.magazine-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.magazine-card {
@apply relative transition-all duration-500 ease-out hover:-translate-y-1;
/* Eyebrow / kicker label */
.eyebrow {
@apply text-[10px] uppercase tracking-[0.35em] font-bold text-nature;
}
/* Buttons */
.btn-primary {
@apply bg-nature text-white font-medium px-6 py-2.5 rounded-lg hover:bg-nature-light hover:shadow-lg transition-all active:scale-95 text-xs uppercase tracking-widest border border-transparent;
@apply inline-flex items-center justify-center gap-2 bg-nature text-white font-bold
px-6 py-2.5 rounded-full text-[11px] uppercase tracking-[0.18em]
border border-transparent transition-all duration-300
hover:bg-nature-light hover:shadow-[0_8px_24px_-8px_rgba(21,100,58,0.5)]
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
}
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 bg-transparent text-nature font-bold
px-6 py-2.5 rounded-full text-[11px] uppercase tracking-[0.18em]
border border-nature/30 transition-all duration-300
hover:bg-nature hover:text-white hover:border-nature
active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none;
}
.btn-ghost {
@apply text-paper-muted hover:text-nature hover:bg-nature/5 px-4 py-2 rounded-lg transition-all text-sm font-medium;
@apply inline-flex items-center justify-center gap-2 text-paper-muted font-medium
px-4 py-2 rounded-full text-sm transition-all duration-300
hover:text-nature hover:bg-nature/5;
}
.icon-btn {
@apply inline-flex items-center justify-center w-9 h-9 rounded-full text-nature
border border-nature/15 transition-all duration-300
hover:bg-nature/10 hover:border-nature/30 active:scale-95;
}
/* Inputs */
.input-field {
@apply bg-white border border-carbon-dark text-paper-white px-4 py-3 rounded-lg focus:border-nature/50 focus:ring-1 focus:ring-nature/50 outline-none transition-all placeholder:text-paper-muted/50 shadow-sm transition-all;
@apply w-full bg-carbon-light text-paper-white px-4 py-3 rounded-xl
border border-paper-muted/20 shadow-sm transition-all duration-200
placeholder:text-paper-muted/45
focus:border-nature/50 focus:ring-2 focus:ring-nature/25 outline-none;
}
.dark .input-field {
@apply bg-carbon-light border-white/5 text-paper-white placeholder:text-paper-muted/30;
/* Chip / badge */
.chip {
@apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full
text-[10px] font-bold uppercase tracking-[0.18em]
bg-nature/10 text-nature;
}
/* Surface card */
.surface-card {
@apply bg-carbon-light rounded-2xl border border-paper-muted/12;
box-shadow: var(--shadow-soft);
}
/* ---------- Magazine reading content ---------- */
.magazine-content {
@apply font-reading leading-relaxed text-paper-white/90;
@apply font-reading text-paper-white/90;
line-height: var(--line-height-magazine);
}
.magazine-content p {
@apply mb-8;
}
.magazine-content p { @apply mb-7; }
.magazine-content a {
@apply text-nature border-b border-nature/20 hover:border-nature transition-all no-underline font-medium;
@apply text-nature border-b border-nature/30 hover:border-nature transition-colors no-underline font-medium;
}
.magazine-content h2,
.magazine-content h3 {
@apply text-paper-white font-serif mb-6 mt-12;
@apply text-paper-white font-serif mb-5 mt-12 leading-snug;
}
.magazine-content h2 { @apply text-3xl; }
.magazine-content h3 { @apply text-2xl; }
.magazine-content img {
@apply rounded-xl my-12 shadow-lg mx-auto;
@apply rounded-2xl my-10 shadow-lg mx-auto max-w-full h-auto;
}
.magazine-content blockquote {
@apply border-l-4 border-nature/40 pl-6 italic text-paper-white/80 my-8;
}
.magazine-content ul,
.magazine-content ol {
@apply mb-8 ml-6 space-y-4;
.magazine-content ol { @apply mb-7 ml-6 space-y-3; }
.magazine-content ul { @apply list-disc; }
.magazine-content ol { @apply list-decimal; }
.magazine-content li { @apply pl-1 marker:text-nature; }
.magazine-content pre {
@apply bg-nature-dark/40 text-paper-white rounded-xl p-4 overflow-x-auto my-8 text-sm;
}
.magazine-content code {
@apply font-mono text-[0.9em] text-earth;
}
.magazine-content li {
@apply pl-2 marker:text-nature;
/* Drop cap for immersive reader */
.drop-cap::first-letter {
@apply font-serif text-nature float-left mr-3 mt-2;
font-size: 4.5rem;
line-height: 3rem;
}
}
/* ============================================================
Keyframes & motion utilities
============================================================ */
@layer utilities {
.animate-bounce-slow {
animation: bounce-slow 3s infinite;
}
.text-shadow-sm { text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); }
.animate-fade-in {
animation: fade-in 0.5s ease-out forwards;
}
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.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);
}
}
.animate-fade-in { animation: fade-in 0.5s ease-out forwards; }
.animate-rise { animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
.animate-bounce-slow { animation: bounce-slow 3s infinite; }
.animate-shake { animation: shake 0.4s ease-in-out; }
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
@keyframes rise {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
@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); }
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}
+10 -4
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Sidebar } from '../components/Sidebar';
import { MobileTopBar } from '../components/MobileTopBar';
import { DashboardPage } from '../pages/DashboardPage';
import { FocusPage } from '../pages/FocusPage';
@@ -15,13 +16,18 @@ export function RootLayout() {
onEnterFocus={() => setIsFocusMode(true)}
isFocusMode={isFocusMode}
/>
{isFocusMode ? (
<FocusPage onExit={() => setIsFocusMode(false)} />
) : (
<DashboardPage
selectedFeedId={selectedFeedId}
onEnterFocus={() => setIsFocusMode(true)}
/>
<div className="flex-1 flex flex-col min-w-0">
<MobileTopBar
onSelectFeed={setSelectedFeedId}
selectedFeedId={selectedFeedId}
onEnterFocus={() => setIsFocusMode(true)}
/>
<DashboardPage selectedFeedId={selectedFeedId} onEnterFocus={() => setIsFocusMode(true)} />
</div>
)}
</div>
);
+143 -235
View File
@@ -6,34 +6,22 @@ import { feedsApi } from '../api/feeds';
import { ArticleCard } from '../components/ArticleCard';
import { ReaderView } from '../components/ReaderView';
import { MobileReaderView } from '../components/MobileReaderView';
import { useIsMobile } from '../hooks/useIsMobile';
import { useWebsocket } from '../hooks/useWebsocket';
import type { Article } from '../api/articles';
const breakpointColumnsObj = {
default: 3,
1100: 2,
700: 1
};
const breakpointColumnsObj = { default: 3, 1100: 2, 700: 1 };
const ITEMS_PER_PAGE = 24;
interface DashboardPageProps {
selectedFeedId: string | null;
onEnterFocus: () => void;
}
const ITEMS_PER_PAGE = 24;
type ArticlePages = { pages: Article[][] };
// Helper Hook for Responsive Split
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;
function timestamp(a: Article): number {
return new Date(a.published_at || a.created_at).getTime();
}
export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) {
@@ -43,118 +31,86 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
const [debouncedQuery, setDebouncedQuery] = useState('');
const isMobile = useIsMobile();
const loadMoreRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLElement>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 500);
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 400);
return () => clearTimeout(timer);
}, [searchQuery]);
// Real-time sync
useWebsocket();
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['articles', selectedFeedId, debouncedQuery],
const queryKey = ['articles', selectedFeedId, debouncedQuery];
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 0 }) => {
const options = {
limit: ITEMS_PER_PAGE,
offset: pageParam,
};
if (debouncedQuery) {
return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam);
}
if (selectedFeedId === 'favorites') {
return articlesApi.list({ ...options, favorite: true });
}
const options = { limit: ITEMS_PER_PAGE, offset: pageParam };
if (debouncedQuery) return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam);
if (selectedFeedId === 'favorites') return articlesApi.list({ ...options, favorite: true });
return articlesApi.list({ ...options, feed_id: selectedFeedId || undefined });
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < ITEMS_PER_PAGE) return undefined;
return allPages.length * ITEMS_PER_PAGE;
},
getNextPageParam: (lastPage, allPages) =>
lastPage.length < ITEMS_PER_PAGE ? undefined : allPages.length * ITEMS_PER_PAGE,
});
const articles = data?.pages.flat() || [];
const sortedArticles = useMemo(() => {
return [...articles].sort((a, b) => {
// First, sort by read status: unread (false) first
if (a.is_read !== b.is_read) {
return a.is_read ? 1 : -1;
}
// If same read status, sort by date descending
const dateA = a.published_at || a.created_at;
const dateB = b.published_at || b.created_at;
return new Date(dateB) - new Date(dateA);
const all = data?.pages.flat() ?? [];
return [...all].sort((a, b) => {
if (a.is_read !== b.is_read) return a.is_read ? 1 : -1;
return timestamp(b) - timestamp(a);
});
}, [articles]);
}, [data]);
// Intersection Observer for Infinite Scroll
const unreadCount = useMemo(() => sortedArticles.filter((a) => !a.is_read).length, [sortedArticles]);
// Infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage, isFetchingNextPage]);
// Back-to-top visibility
useEffect(() => {
const el = mainRef.current;
if (!el) return;
const onScroll = () => setShowScrollTop(el.scrollTop > 900);
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, []);
const refreshMutation = useMutation({
mutationFn: () => feedsApi.refresh(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const toggleReadMutation = useMutation({
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
onMutate: async ({ id, is_read }) => {
const queryKey = ['articles', selectedFeedId, debouncedQuery];
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old: any) => {
queryClient.setQueryData(queryKey, (old: ArticlePages | undefined) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page: Article[]) =>
page.map((article) =>
article.id === id ? { ...article, is_read: is_read } : article
)
),
pages: old.pages.map((page) => page.map((a) => (a.id === id ? { ...a, is_read } : a))),
};
});
return { previousData };
},
onError: (_err, _newTodo, context) => {
const queryKey = ['articles', selectedFeedId, debouncedQuery];
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
onError: (_e, _v, context) => {
if (context?.previousData) queryClient.setQueryData(queryKey, context.previousData);
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const toggleFavoriteMutation = useMutation({
@@ -162,156 +118,116 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const [showScrollTop, setShowScrollTop] = useState(false);
const mainRef = useRef<HTMLElement>(null);
// Visibility logic for "Back to Top" button
useEffect(() => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
setShowScrollTop(target.scrollTop > 1000);
};
const mainElement = mainRef.current;
if (mainElement) {
mainElement.addEventListener('scroll', handleScroll);
}
return () => mainElement?.removeEventListener('scroll', handleScroll);
}, []);
const scrollToTop = () => {
mainRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
};
const heading = debouncedQuery
? `Résultats : « ${debouncedQuery} »`
: selectedFeedId === 'favorites'
? 'Mes favoris'
: selectedFeedId
? 'Archives du flux'
: 'La Une';
if (isLoading) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-carbon space-y-4">
<div className="w-12 h-12 border-t-2 border-nature rounded-full animate-spin"></div>
<p className="text-paper-muted text-[10px] uppercase tracking-[0.4em] font-black">Composition de votre édition...</p>
<div className="flex-1 flex flex-col items-center justify-center gap-4 bg-carbon">
<div className="w-11 h-11 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
<p className="eyebrow text-paper-muted">Composition de votre édition…</p>
</div>
);
}
return (
<main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon scroll-smooth relative">
{/* Back to Top Button */}
<main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon relative">
{/* Back to top */}
<button
onClick={scrollToTop}
className={`fixed bottom-8 right-12 z-40 p-4 rounded-full bg-white border border-nature/10 text-nature shadow-2xl transition-all duration-500 hover:scale-110 active:scale-95 ${showScrollTop ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'}`}
onClick={() => mainRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className={`fixed bottom-8 right-8 z-40 icon-btn w-12 h-12 bg-carbon-light shadow-lg transition-all duration-300 ${
showScrollTop ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'
}`}
title="Retour en haut"
aria-label="Retour en haut"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
<div className="max-w-[1400px] mx-auto px-12 py-16">
{/* Header */}
<header className="mb-20 space-y-12 border-b border-white/5 pb-10">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-nature text-[10px] uppercase tracking-[0.5em] font-black">Édition du jour</p>
<h1 className="text-6xl font-serif italic text-paper-white tracking-tighter">
{debouncedQuery ? `Résultats : "${debouncedQuery}"` : selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'}
<div className="max-w-[1400px] mx-auto px-6 md:px-12 py-10 md:py-14">
{/* Masthead */}
<header className="mb-12 border-b border-paper-muted/15 pb-8">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
<div>
<p className="eyebrow mb-2">Édition du jour</p>
<h1 className="text-5xl md:text-6xl font-serif italic text-paper-white tracking-tight text-balance">
{heading}
</h1>
<p className="text-paper-muted text-sm mt-3 font-reading">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
{unreadCount > 0 && <span className="text-nature"> · {unreadCount} non lus</span>}
</p>
</div>
<div className="flex flex-col items-end gap-6">
<div className="flex flex-col items-end text-right">
<p className="text-nature text-sm font-serif italic font-bold tracking-wide">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
<p className="text-[10px] text-paper-muted opacity-80 uppercase tracking-[0.25em] font-black mt-1">
Volume IX • No. 42
</p>
</div>
<div className="flex items-center gap-3">
<div className="relative group">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-paper-muted/40 group-focus-within:text-nature transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="RECHERCHER DANS L'ÉDITION..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-full text-[10px] text-paper-white placeholder-paper-muted/40 font-black tracking-widest focus:outline-none focus:ring-1 focus:ring-nature focus:border-nature transition-all w-48 focus:w-72"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-3 flex items-center text-paper-muted hover:text-paper-white transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className={`text-[10px] uppercase tracking-[0.2em] font-bold px-6 py-2 rounded-full border border-nature/30 text-nature hover:bg-nature hover:text-white hover:border-nature transition-all duration-300 ${refreshMutation.isPending ? 'animate-pulse opacity-50' : 'hover:shadow-lg hover:shadow-nature/20'}`}
>
{refreshMutation.isPending ? 'Mise à jour' : 'Actualiser'}
</button>
<div className="flex items-center gap-3">
<div className="relative group">
<span className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-paper-muted/50 group-focus-within:text-nature transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
type="search"
aria-label="Rechercher dans l'édition"
placeholder="Rechercher…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input-field pl-10 pr-4 py-2.5 rounded-full w-44 focus:w-64 transition-all text-sm"
/>
</div>
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className="btn-secondary"
>
<svg className={`w-3.5 h-3.5 ${refreshMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{refreshMutation.isPending ? 'Mise à jour' : 'Actualiser'}
</button>
</div>
</div>
</header>
{/* Focus Mode CTA Widget - Compact Version */}
{!selectedFeedId && sortedArticles && sortedArticles.length > 0 && (
<div className="mb-12 relative overflow-hidden rounded-xl bg-gradient-to-r from-nature to-nature-light shadow-xl items-center flex flex-col md:flex-row p-6 hover:scale-[1.01] transition-transform cursor-pointer group" onClick={onEnterFocus}>
{/* decorative circles */}
<div className="absolute -top-12 -right-12 w-48 h-48 bg-white/10 rounded-full blur-2xl group-hover:bg-white/20 transition-colors pointer-events-none"></div>
<div className="absolute -bottom-12 -left-12 w-48 h-48 bg-black/10 rounded-full blur-2xl pointer-events-none"></div>
<div className="flex-1 z-10 text-center md:text-left mb-4 md:mb-0">
<div className="flex items-center justify-center md:justify-start space-x-2 mb-1">
<span className="bg-white/20 text-white text-[9px] font-bold px-1.5 py-0.5 rounded uppercase tracking-widest backdrop-blur-sm">
Nouveauté
</span>
</div>
<h2 className="text-2xl font-serif italic text-white mb-1 text-shadow-sm">
Votre session de lecture
</h2>
<p className="text-white/80 font-medium text-xs max-w-lg">
{sortedArticles.filter(a => !a.is_read).length} articles non lus. Passez en mode Focus.
</p>
</div>
<div className="z-10">
<button
onClick={(e) => { e.stopPropagation(); onEnterFocus(); }}
className="bg-white text-nature font-bold text-xs uppercase tracking-widest px-6 py-3 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-4 h-4 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>
{/* Focus CTA */}
{!selectedFeedId && !debouncedQuery && unreadCount > 0 && (
<button
onClick={onEnterFocus}
className="group w-full mb-10 relative overflow-hidden rounded-2xl bg-nature text-white flex items-center justify-between p-6 text-left transition-transform hover:scale-[1.005]"
>
<span className="absolute -top-12 -right-12 w-48 h-48 bg-white/10 rounded-full blur-2xl pointer-events-none" />
<span className="relative z-10">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-white/70">Mode Focus</span>
<span className="block text-2xl font-serif italic mt-1">Votre session de lecture</span>
<span className="block text-white/80 text-sm mt-1">{unreadCount} articles non lus à trier d'un geste.</span>
</span>
<span className="relative z-10 btn-primary bg-white !text-nature group-hover:!bg-white/90">
Lancer
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</span>
</button>
)}
{/* Grid */}
{sortedArticles.length > 0 ? (
<>
<Masonry
breakpointCols={breakpointColumnsObj}
className="flex w-auto -ml-6"
columnClassName="pl-6 bg-clip-padding"
>
{sortedArticles.map((article, index) => (
<div key={`${article.id}-${index}`} className="mb-6">
<Masonry breakpointCols={breakpointColumnsObj} className="flex w-auto -ml-6" columnClassName="pl-6 bg-clip-padding">
{sortedArticles.map((article) => (
<div key={article.id} className="mb-6">
<ArticleCard
article={article}
onClick={(article) => {
setSelectedArticle(article);
if (!article.is_read) {
toggleReadMutation.mutate({ id: article.id, is_read: true });
}
onClick={(a) => {
setSelectedArticle(a);
if (!a.is_read) toggleReadMutation.mutate({ id: a.id, is_read: true });
}}
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
@@ -320,31 +236,30 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
))}
</Masonry>
{/* Infinite Scroll Sentinel */}
<div ref={loadMoreRef} className="mt-20 py-10 flex flex-col items-center justify-center space-y-4">
<div ref={loadMoreRef} className="mt-16 py-8 flex flex-col items-center gap-3">
{isFetchingNextPage ? (
<>
<div className="w-8 h-8 border-t-2 border-nature rounded-full animate-spin"></div>
<p className="text-nature text-[8px] uppercase tracking-[0.4em] font-black">Chargement supplémentaire...</p>
<div className="w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
<p className="eyebrow text-paper-muted/60">Chargement…</p>
</>
) : hasNextPage ? (
<p className="text-paper-muted/20 text-[8px] uppercase tracking-[0.4em] font-black italic">Défilez pour découvrir la suite</p>
<p className="text-paper-muted/40 text-[10px] uppercase tracking-[0.3em] font-bold">Défilez pour la suite</p>
) : (
<div className="flex flex-col items-center space-y-2">
<div className="h-[1px] w-20 bg-nature/20"></div>
<p className="text-paper-muted/40 text-[8px] uppercase tracking-[0.4em] font-black">Fin de votre édition</p>
<div className="h-[1px] w-20 bg-nature/20"></div>
<div className="flex items-center gap-3">
<span className="h-px w-16 bg-nature/20" />
<p className="eyebrow text-paper-muted/50">Fin de l'édition</p>
<span className="h-px w-16 bg-nature/20" />
</div>
)}
</div>
</>
) : (
<div className="flex flex-col items-center justify-center py-40 border-2 border-dashed border-white/5 rounded-3xl">
<p className="text-paper-muted font-serif italic text-xl mb-4">
{debouncedQuery ? `Aucun article ne correspond à "${debouncedQuery}".` : 'Votre bibliothèque est vide.'}
<div className="flex flex-col items-center justify-center py-32 border border-dashed border-paper-muted/20 rounded-3xl text-center px-6">
<p className="text-paper-muted font-serif italic text-xl mb-2">
{debouncedQuery ? `Aucun article pour « ${debouncedQuery} ».` : 'Votre bibliothèque est vide.'}
</p>
<p className="text-paper-muted/40 text-[10px] uppercase tracking-widest font-bold">
{debouncedQuery ? "Tentez une recherche plus large." : "Ajoutez un flux pour commencer votre lecture."}
<p className="eyebrow text-paper-muted/50">
{debouncedQuery ? 'Tentez une recherche plus large.' : 'Ajoutez un flux pour commencer.'}
</p>
</div>
)}
@@ -357,14 +272,11 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
onClose={() => setSelectedArticle(null)}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
onNext={() => {
if (!sortedArticles) return;
const idx = sortedArticles.findIndex(a => a.id === selectedArticle.id);
const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
if (idx < sortedArticles.length - 1) {
const nextArticle = sortedArticles[idx + 1];
setSelectedArticle(nextArticle);
if (!nextArticle.is_read) {
toggleReadMutation.mutate({ id: nextArticle.id, is_read: true });
}
const next = sortedArticles[idx + 1];
setSelectedArticle(next);
if (!next.is_read) toggleReadMutation.mutate({ id: next.id, is_read: true });
} else if (hasNextPage) {
fetchNextPage();
} else {
@@ -372,12 +284,8 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
}
}}
onPrev={() => {
if (!sortedArticles) return;
const idx = sortedArticles.findIndex(a => a.id === selectedArticle.id);
if (idx > 0) {
const prevArticle = sortedArticles[idx - 1];
setSelectedArticle(prevArticle);
}
const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
if (idx > 0) setSelectedArticle(sortedArticles[idx - 1]);
}}
/>
) : (
+35 -69
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { articlesApi } from '../api/articles';
import { articlesApi, type Article } from '../api/articles';
import { FocusCardStack } from '../components/focus/FocusCardStack';
import { FocusEmptyState } from '../components/focus/FocusEmptyState';
@@ -12,102 +12,68 @@ 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
queryFn: () => articlesApi.list({ unread: true, limit: 100 }),
staleTime: 0,
});
// Stable deck state to prevent UI jumping when articles are marked read
const [deck, setDeck] = useState<typeof articles>([]);
// Initialize/Append deck when data arrives
if (articles && (!deck || deck.length === 0)) {
// Snapshot the unread set once into a stable deck for the whole session,
// so marking articles read mid-session never reshuffles the stack. This is
// the React-endorsed "adjust state during render" pattern (guarded to run
// exactly once), not an effect.
const [deck, setDeck] = useState<Article[]>([]);
const [seeded, setSeeded] = useState(false);
if (!seeded && articles && articles.length > 0) {
setDeck(articles);
setSeeded(true);
}
// Note: A more robust implementation would append new unique items if we implemented pagination/infinite scroll,
// but for now, snapshotting the initial load is sufficient for the "Focus Session" concept.
// Mutation to mark read
const markReadMutation = useMutation({
mutationFn: (id: string) => articlesApi.markRead(id),
onSuccess: () => {
// Invalidate main lists to sync up sidebar, but our local 'deck' state won't change
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['feeds'] });
},
});
// 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 invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['feeds'] });
};
const handleToggleFavorite = (id: string) => {
toggleFavoriteMutation.mutate(id);
};
const markReadMutation = useMutation({ mutationFn: (id: string) => articlesApi.markRead(id), onSuccess: invalidate });
const toggleFavoriteMutation = useMutation({ mutationFn: (id: string) => articlesApi.toggleFavorite(id), onSuccess: invalidate });
const handleKeep = (id: string) => {
// Do nothing api-wise, just skip.
console.log('Skipped:', id);
};
if (isLoading && (!deck || deck.length === 0)) {
if (isLoading && deck.length === 0) {
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 className="flex items-center justify-center h-full bg-carbon/60 backdrop-blur-xl">
<div className="w-14 h-14 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
</div>
);
}
// Filter out locally read? No, we handle that in stack index.
// If deck is empty
if (!deck || deck.length === 0) {
return <FocusEmptyState onBack={onExit} />;
}
if (isComplete) {
return <FocusEmptyState onBack={onExit} />;
if (deck.length === 0 || isComplete) {
return (
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-2xl flex items-center justify-center">
<FocusEmptyState onBack={onExit} />
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-3xl flex flex-col">
{/* Header */}
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-2xl flex flex-col">
<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 className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-earth animate-pulse" />
<span className="eyebrow text-paper-white">Mode Focus</span>
</div>
<button
onClick={onExit}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white border border-nature/10 text-nature-dark shadow-lg hover:bg-nature hover:text-white hover:border-nature hover:scale-105 transition-all duration-300 group"
title="Quitter le mode Focus"
>
<span className="text-[10px] font-bold uppercase tracking-widest hidden sm:inline-block">
Retour au Dashboard
</span>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<button onClick={onExit} className="btn-secondary" title="Quitter le mode Focus">
<span className="hidden sm:inline">Tableau de bord</span>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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={deck}
onMarkRead={handleMarkRead}
onKeep={handleKeep}
onToggleFavorite={handleToggleFavorite}
onMarkRead={(id) => markReadMutation.mutate(id)}
onKeep={() => { /* skip — no API action */ }}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
onEmpty={() => setIsComplete(true)}
/>
</main>
+62 -67
View File
@@ -1,17 +1,22 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth';
import { ApiError } from '../api/client';
import { useAuthStore } from '../stores/authStore';
import { AuthShell } from '../components/AuthShell';
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const setUser = useAuthStore((state) => state.setUser);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const flashMessage = (location.state as { message?: string } | null)?.message;
const loginMutation = useMutation({
mutationFn: authApi.login,
onSuccess: (data) => {
@@ -19,11 +24,7 @@ export function LoginPage() {
navigate('/');
},
onError: (err: Error) => {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError('Échec de la connexion. Veuillez réessayer.');
}
setError(err instanceof ApiError ? err.message : 'Échec de la connexion. Veuillez réessayer.');
},
});
@@ -34,70 +35,64 @@ export function LoginPage() {
};
return (
<div className="min-h-screen bg-carbon flex items-center justify-center p-6">
<div className="w-full max-w-md bg-carbon-light p-10 rounded-xl shadow-2xl border border-carbon-dark/50 animate-in fade-in zoom-in duration-500">
<div className="text-center mb-10">
<h1 className="text-gold text-4xl font-serif mb-2 italic tracking-tight">FlowReader</h1>
<p className="text-paper-muted text-[10px] uppercase tracking-[0.3em] font-bold">Magazine Personnel</p>
<AuthShell eyebrow="Magazine personnel" title="Bon retour" subtitle="Reprenez votre veille là où vous l'avez laissée.">
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{flashMessage && !error && (
<p className="text-nature text-xs bg-nature/10 border border-nature/20 rounded-xl p-3" role="status">
{flashMessage}
</p>
)}
{error && (
<p className="text-danger text-xs bg-danger/10 border border-danger/30 rounded-xl p-3 animate-shake" role="alert">
{error}
</p>
)}
<div className="space-y-2">
<label htmlFor="email" className="block eyebrow text-paper-muted">Email</label>
<input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input-field"
placeholder="vous@exemple.com"
required
autoFocus
/>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-500/10 border border-red-500/50 text-red-500 text-xs p-3 rounded-md animate-shake">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="password" className="block eyebrow text-paper-muted">Mot de passe</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-field"
placeholder="••••••••"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
placeholder="vous@exemple.com"
required
autoFocus
/>
</div>
<motion.button
type="submit"
disabled={loginMutation.isPending}
whileTap={{ scale: 0.98 }}
className="btn-primary w-full py-4 text-xs"
>
{loginMutation.isPending ? 'Authentification…' : 'Continuer'}
</motion.button>
</form>
<div className="space-y-2">
<label htmlFor="password" title="password" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
Mot de passe
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full btn-primary py-4 text-xs uppercase tracking-[0.2em] font-bold mt-4 disabled:opacity-50 disabled:cursor-not-wait"
>
{loginMutation.isPending ? 'Authentification...' : 'Continuer'}
</button>
</form>
<p className="mt-8 text-center text-paper-muted text-xs">
Nouveau chez FlowReader ?{' '}
<button
className="text-gold hover:underline font-bold tracking-wide"
onClick={() => navigate('/register')}
>
Créer un compte
</button>
</p>
</div>
</div>
<p className="mt-8 text-center text-paper-muted text-sm">
Nouveau chez FlowReader ?{' '}
<button onClick={() => navigate('/register')} className="text-nature font-bold hover:underline underline-offset-4">
Créer un compte
</button>
</p>
</AuthShell>
);
}
+69 -84
View File
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth';
import { ApiError } from '../api/client';
import { AuthShell } from '../components/AuthShell';
export function RegisterPage() {
const navigate = useNavigate();
@@ -17,107 +19,90 @@ export function RegisterPage() {
navigate('/login', { state: { message: 'Compte créé ! Vous pouvez vous connecter.' } });
},
onError: (err: Error) => {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError('L\'inscription a échoué. Veuillez réessayer.');
}
setError(err instanceof ApiError ? err.message : "L'inscription a échoué. Veuillez réessayer.");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setError('Les mots de passe ne correspondent pas.');
return;
}
registerMutation.mutate({ email, password });
};
return (
<div className="min-h-screen bg-carbon flex items-center justify-center p-6">
<div className="w-full max-w-md bg-carbon-light p-10 rounded-xl shadow-2xl border border-carbon-dark/50 animate-in fade-in zoom-in duration-500">
<div className="text-center mb-10">
<h1 className="text-gold text-4xl font-serif mb-2 italic tracking-tight">FlowReader</h1>
<p className="text-paper-muted text-[10px] uppercase tracking-[0.3em] font-bold">Rejoindre l'Édition</p>
<AuthShell eyebrow="Rejoindre l'édition" title="Créez votre journal" subtitle="Une veille calme, intelligente et sans bruit.">
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
{error && (
<p className="text-danger text-xs bg-danger/10 border border-danger/30 rounded-xl p-3 animate-shake" role="alert">
{error}
</p>
)}
<div className="space-y-2">
<label htmlFor="email" className="block eyebrow text-paper-muted">Email</label>
<input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input-field"
placeholder="vous@exemple.com"
required
autoFocus
/>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-500/10 border border-red-500/50 text-red-500 text-xs p-3 rounded-md animate-shake">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="password" className="block eyebrow text-paper-muted">Mot de passe</label>
<input
id="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-field"
placeholder="Au moins 8 caractères"
required
minLength={8}
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
placeholder="vous@exemple.com"
required
/>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="block eyebrow text-paper-muted">Confirmer le mot de passe</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="input-field"
placeholder="••••••••"
required
minLength={8}
/>
</div>
<div className="space-y-2">
<label htmlFor="password" title='password' className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
Mot de passe
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
placeholder="••••••••"
required
minLength={8}
/>
</div>
<motion.button
type="submit"
disabled={registerMutation.isPending}
whileTap={{ scale: 0.98 }}
className="btn-primary w-full py-4 text-xs"
>
{registerMutation.isPending ? 'Création…' : 'Créer mon compte'}
</motion.button>
</form>
<div className="space-y-2">
<label htmlFor="confirmPassword" title='confirm password' className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
placeholder="••••••••"
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={registerMutation.isPending}
className="w-full btn-primary py-4 text-xs uppercase tracking-[0.2em] font-bold mt-4 disabled:opacity-50 disabled:cursor-wait"
>
{registerMutation.isPending ? 'Création...' : 'Créer mon compte'}
</button>
</form>
<p className="mt-8 text-center text-paper-muted text-xs">
Déjà membre ?{' '}
<button
className="text-gold hover:underline font-bold tracking-wide"
onClick={() => navigate('/login')}
>
Se connecter
</button>
</p>
</div>
</div>
<p className="mt-8 text-center text-paper-muted text-sm">
Déjà membre ?{' '}
<button onClick={() => navigate('/login')} className="text-nature font-bold hover:underline underline-offset-4">
Se connecter
</button>
</p>
</AuthShell>
);
}