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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { feedsApi } from '../api/feeds'; import { feedsApi } from '../api/feeds';
interface AddFeedModalProps { interface AddFeedModalProps {
@@ -9,10 +10,11 @@ interface AddFeedModalProps {
export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) { export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const addFeedMutation = useMutation({ const addFeedMutation = useMutation({
mutationFn: (url: string) => feedsApi.add({ url }), mutationFn: (u: string) => feedsApi.add({ url: u }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] }); queryClient.invalidateQueries({ queryKey: ['feeds'] });
setUrl(''); 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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
addFeedMutation.mutate(url); addFeedMutation.mutate(url);
}; };
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
};
return ( 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}> <AnimatePresence>
<div {isOpen && (
className="w-full max-w-md paper-panel p-8 rounded-2xl shadow-2xl border border-nature/10 animate-in zoom-in duration-300" <motion.div
onClick={(e) => e.stopPropagation()} className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-nature-dark/60 backdrop-blur-sm"
> initial={{ opacity: 0 }}
<div className="flex justify-between items-center mb-6"> animate={{ opacity: 1 }}
<h2 className="text-2xl font-serif text-nature italic">Nouveau Flux</h2> exit={{ opacity: 0 }}
<button onClick={onClose} className="text-paper-muted hover:text-white transition-colors"> onClick={onClose}
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> role="dialog"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M6 18L18 6M6 6l12 12" /> aria-modal="true"
</svg> aria-labelledby="add-feed-title"
</button> >
</div> <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"> <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 automatiquement le titre et le contenu. Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera le titre et le contenu automatiquement.
</p> </p>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-[10px] uppercase tracking-[0.2em] font-bold text-paper-muted">URL du Flux</label> <label htmlFor="feed-url" className="block eyebrow text-paper-muted">URL du flux</label>
<input <input
type="url" id="feed-url"
value={url} type="url"
onChange={(e) => setUrl(e.target.value)} value={url}
className="input-field w-full" onChange={(e) => setUrl(e.target.value)}
placeholder="https://exemple.com/feed" className="input-field"
required placeholder="https://exemple.com/feed"
autoFocus required
/> autoFocus
</div> />
</div>
{addFeedMutation.isError && ( {addFeedMutation.isError && (
<p className="text-red-400 text-xs italic">Échec de l'ajout. Vérifiez l'URL du flux.</p> <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"> <div className="flex justify-end gap-3">
<button <button type="button" onClick={onClose} className="btn-ghost">Annuler</button>
type="button" <button type="submit" disabled={url.length < 5 || addFeedMutation.isPending} className="btn-primary">
onClick={onClose} {addFeedMutation.isPending ? 'Ajout…' : "S'abonner"}
className="btn-ghost" </button>
> </div>
Annuler </form>
</button>
<button {/* OPML import */}
type="submit" <div className="mt-6 pt-5 border-t border-paper-muted/12">
disabled={url.length < 5 || addFeedMutation.isPending} <input ref={fileInputRef} type="file" accept=".opml,.xml,text/xml" className="hidden" onChange={handleFile} />
className="btn-primary" <button
> onClick={() => fileInputRef.current?.click()}
{addFeedMutation.isPending ? 'Ajout...' : 'S\'abonner'} disabled={importMutation.isPending}
</button> className="btn-ghost w-full justify-center"
</div> >
</form> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
</div> <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" />
</div> </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; 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) { export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
const [swipeOffset, setSwipeOffset] = useState(0); const [swipeOffset, setSwipeOffset] = useState(0);
const [isSwiping, setIsSwiping] = useState(false); const [isSwiping, setIsSwiping] = useState(false);
@@ -16,23 +24,17 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
const handlers = useSwipeable({ const handlers = useSwipeable({
onSwiping: (event) => { onSwiping: (event) => {
const offset = event.deltaX; if (Math.abs(event.deltaX) < 100) {
// Limit swipe to 80px setSwipeOffset(event.deltaX);
if (Math.abs(offset) < 100) {
setSwipeOffset(offset);
setIsSwiping(true); setIsSwiping(true);
} }
}, },
onSwipedRight: () => { onSwipedRight: () => {
if (swipeOffset > 50) { if (swipeOffset > 50) onToggleRead(article.id, !article.is_read);
onToggleRead(article.id, !article.is_read);
}
resetSwipe(); resetSwipe();
}, },
onSwipedLeft: () => { onSwipedLeft: () => {
if (swipeOffset < -50) { if (swipeOffset < -50) onToggleFavorite(article.id);
onToggleFavorite(article.id);
}
resetSwipe(); resetSwipe();
}, },
onSwiped: () => resetSwipe(), onSwiped: () => resetSwipe(),
@@ -49,156 +51,120 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
const handleSummarize = async (e: React.MouseEvent) => { const handleSummarize = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (article.ai_summary || isSummarizing) return; if (article.ai_summary || isSummarizing) return;
setIsSummarizing(true); setIsSummarizing(true);
try { try {
await articlesApi.summarize(article.id); await articlesApi.summarize(article.id);
// The article will be updated via WebSocket broadcast
} catch (error) { } catch (error) {
console.error('Failed to summarize:', 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 { } finally {
setIsSummarizing(false); setIsSummarizing(false);
} }
}; };
return ( return (
<div className="relative overflow-visible pb-4"> <div className="relative">
{/* Action Layers behind the card */} {/* Swipe action hints behind the card */}
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none"> <div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none">
{/* Left side swipe (Mark as Read) */} <span
<div className={`flex items-center space-x-2 transition-opacity duration-300 ${swipeOffset > 10 ? 'opacity-100' : 'opacity-0'}`}> className={`chip 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"> {article.is_read ? 'Marquer non lu' : 'Marquer lu'}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /> </span>
</svg> <span
</div> className={`chip bg-earth/15 text-earth transition-opacity duration-300 ${swipeOffset < -10 ? 'opacity-100' : 'opacity-0'}`}
<span className="text-nature font-black text-[10px] uppercase tracking-widest"> >
{article.is_read ? 'Marquer non lu' : 'Marquer lu'} Favori
</span> </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>
</div> </div>
{/* Main Interactive Card */}
<article <article
{...handlers} {...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={{ style={{
transform: `translateX(${swipeOffset}px)`, 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)}
> >
{/* Cover */}
{/* Unread Ribbon - Polished Wrapping Effect */} <div className="aspect-[16/9] overflow-hidden relative bg-carbon-dark">
{!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 */}
<div <div
className="w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105" className="w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
style={{ style={{ backgroundImage: `url(${article.image_url || FALLBACK_IMAGE})` }}
backgroundImage: `url(${article.image_url || 'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800'})`, />
backgroundColor: '#f0f0f0' <div className="absolute inset-0 bg-gradient-to-t from-black/45 via-transparent to-transparent" />
}}
></div>
{/* Meta on top of image */} <span className="absolute bottom-3 left-3 chip bg-carbon-light/90 backdrop-blur-sm shadow-sm">
<div className="absolute bottom-4 left-4 right-4 z-20 flex justify-between items-end"> {article.feed_title || 'Journal'}
<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"> </span>
{article.feed_title || 'Journal'}
</span> {!article.is_read && (
</div> <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> </div>
{/* Content */} {/* Body */}
<div className="flex-1 flex flex-col px-5 gap-3"> <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 duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-black'}`} <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} {article.title}
</h2> </h2>
{article.ai_summary ? ( {article.ai_summary ? (
<div className="flex items-center space-x-2 text-nature/80"> <span className="chip self-start">✨ Résumé disponible</span>
<span className="text-sm px-2 py-0.5 bg-nature/10 rounded-full font-medium flex items-center"> ) : article.summary ? (
<span className="mr-1">✨</span> Résumé <p className="text-paper-muted text-sm leading-relaxed line-clamp-2 font-reading">
</span> {plainExcerpt(article.summary)}
</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)}...
</p> </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="mt-auto flex items-center justify-between pt-3 border-t border-paper-muted/10">
<div className="flex space-x-2"> <div className="flex items-center gap-1">
<button <button
onClick={(e) => { onClick={(e) => { e.stopPropagation(); onToggleRead(article.id, !article.is_read); }}
e.stopPropagation(); className={`p-2 rounded-full border transition-all
onToggleRead(article.id, !article.is_read); ${article.is_read
}} ? 'text-nature/40 border-transparent hover:bg-nature/5'
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'}`} : 'text-nature border-nature/20 hover:bg-nature/10'}`}
title={article.is_read ? "Marquer comme non lu" : "Marquer comme lu"} 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg> </svg>
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => { e.stopPropagation(); onToggleFavorite(article.id); }}
e.stopPropagation(); className={`p-2 rounded-full transition-all ${article.is_favorite ? 'text-earth' : 'text-paper-muted hover:text-earth hover:bg-earth/10'}`}
onToggleFavorite(article.id); title={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
}} aria-label={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
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'}`} aria-pressed={article.is_favorite}
title="Ajouter aux favoris"
> >
<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" /> <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> </svg>
</button> </button>
<button <button
onClick={handleSummarize} onClick={handleSummarize}
disabled={isSummarizing || !!article.ai_summary} disabled={isSummarizing || !!article.ai_summary}
className={`p-1.5 rounded-full transition-all duration-300 flex items-center justify-center ${article.ai_summary 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'}`}
? 'text-nature bg-nature/10 opacity-50 cursor-default' title={article.ai_summary ? 'Résumé déjà généré' : 'Générer un résumé IA'}
: isSummarizing aria-label={article.ai_summary ? 'Résumé déjà généré' : 'Générer un résumé IA'}
? '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)"}
> >
{isSummarizing ? ( {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" /> <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> </svg>
) : ( ) : (
@@ -207,14 +173,14 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
</button> </button>
</div> </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', { {new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
day: 'numeric', day: 'numeric',
month: 'short', month: 'short',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit',
})} })}
</span> </time>
</div> </div>
</div> </div>
</article> </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 { useSwipeable } from 'react-swipeable';
import { motion } from 'framer-motion';
import { ShareButton } from './ShareButton'; import { ShareButton } from './ShareButton';
import { type Article, articlesApi } from '../api/articles';
interface MobileReaderViewProps { interface MobileReaderViewProps {
article: Article; article: Article;
@@ -11,117 +13,130 @@ interface MobileReaderViewProps {
} }
export function MobileReaderView({ article, onClose, onToggleFavorite, onNext, onPrev }: MobileReaderViewProps) { export function MobileReaderView({ article, onClose, onToggleFavorite, onNext, onPrev }: MobileReaderViewProps) {
// Determine content to show: full content > summary > default message const [aiSummary, setAiSummary] = useState(article.ai_summary);
let displayContent = article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>'; const [isSummarizing, setIsSummarizing] = useState(false);
// If we have a hero image, try to remove the first image from the content to avoid duplicates let displayContent =
if (article.image_url) { article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
displayContent = displayContent.replace(/<img[^>]*>/, ''); if (article.image_url) displayContent = displayContent.replace(/<img[^>]*>/, '');
}
// Swipe handlers
const handlers = useSwipeable({ const handlers = useSwipeable({
onSwipedLeft: () => onNext(), onSwipedLeft: () => onNext(),
onSwipedRight: () => onPrev(), onSwipedRight: () => onPrev(),
preventScrollOnSwipe: false, 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 ( return (
<div <motion.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" 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} onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={article.title}
> >
<div <motion.div
{...handlers} {...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()} 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 && ( {article.image_url && (
<div className="w-full h-[40vh] relative overflow-hidden"> <div className="w-full h-[38vh] relative overflow-hidden">
<img <img src={article.image_url} alt="" className="w-full h-full object-cover" />
src={article.image_url} <div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent" />
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> </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"> <header className="mb-8">
<div className="flex items-center space-x-2 text-nature text-[10px] uppercase tracking-[0.3em] font-black mb-4"> <div className="flex flex-wrap items-center gap-2 mb-4">
<span className="bg-nature/10 px-2 py-1 rounded">{article.feed_title}</span> <span className="chip">{article.feed_title}</span>
<span className="opacity-30">•</span> <span className="text-paper-muted/40">·</span>
<span>{article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }) : 'Aujourd\'hui'}</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> </div>
<h1 className="text-3xl font-serif text-paper-white leading-tight tracking-tight text-balance">
<h1 className="text-3xl font-serif text-paper-white leading-[1.2] italic-nature tracking-tight">
{article.title} {article.title}
</h1> </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> </header>
<div <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 }} dangerouslySetInnerHTML={{ __html: displayContent }}
/> />
{/* Mobile Action Bar */} <div className="flex items-center justify-between py-5 border-y border-paper-muted/12 mb-8">
<div className="flex items-center justify-between py-6 border-y border-white/5 mb-8">
<ShareButton article={article} /> <ShareButton article={article} />
<button <button
onClick={() => onToggleFavorite(article.id)} 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 className={`flex items-center gap-2 px-4 py-2 rounded-full border text-[11px] uppercase tracking-[0.18em] font-bold transition-all ${
? 'bg-nature text-white border-nature' article.is_favorite ? 'bg-earth text-white border-earth' : 'border-earth/30 text-earth'
: 'border-paper-white/20 text-nature hover:bg-nature/5' }`}
}`} 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" /> <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> </svg>
<span className="text-[10px] uppercase tracking-[0.2em] font-black"> {article.is_favorite ? 'Favori' : 'Ajouter'}
{article.is_favorite ? 'Favori' : 'Favori'}
</span>
</button> </button>
{article.url && (
<a <a href={article.url} target="_blank" rel="noopener noreferrer" className="icon-btn" aria-label="Source d'origine">
href={article.url} <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
target="_blank" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
rel="noopener noreferrer" </svg>
className="p-3 bg-white/5 rounded-full text-paper-muted hover:text-white transition-colors" </a>
> )}
<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>
</div> </div>
<footer className="text-center pb-24 opacity-60"> <footer className="text-center pb-10 opacity-60">
<p className="text-[10px] uppercase tracking-widest text-paper-muted animate-pulse"> <p className="eyebrow text-paper-muted animate-pulse">Glissez pour lire la suite</p>
Swipe pour lire la suite
</p>
</footer> </footer>
</div> </div>
</div> </motion.div>
{/* Fixed Close Button for Mobile - Top Right */} <button
<div className="fixed top-6 right-6 z-50 pointer-events-none"> onClick={(e) => { e.stopPropagation(); onClose(); }}
<button 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"
onClick={(e) => { aria-label="Fermer"
e.stopPropagation(); >
onClose(); <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" />
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>
> </button>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </motion.div>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</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) { if (isLoading) {
return ( return (
<div className="loading-screen"> <div className="min-h-screen flex flex-col items-center justify-center gap-5 bg-carbon">
<div className="spinner"></div> <div className="w-11 h-11 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
<p>Chargement...</p> <p className="eyebrow text-paper-muted">Chargement…</p>
</div> </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 { ShareButton } from './ShareButton';
import { type Article, articlesApi } from '../api/articles'; 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 [aiSummary, setAiSummary] = useState(article.ai_summary);
const [isSummarizing, setIsSummarizing] = useState(false); const [isSummarizing, setIsSummarizing] = useState(false);
// Determine content to show: full content > summary > default message let displayContent =
let displayContent = article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>'; 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 // Close on Escape
if (article.image_url) { useEffect(() => {
// Simple regex to remove the first img tag if it exists at the start of the content const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
// This is a heuristic and might need refinement, but handles the "duplicate" complaint common in RSS window.addEventListener('keydown', onKey);
displayContent = displayContent.replace(/<img[^>]*>/, ''); 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 ( return (
<div <motion.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" 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} onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={article.title}
> >
<div <motion.article
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" 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()} onClick={(e) => e.stopPropagation()}
> >
{/* Desktop close */}
{/* Close button for desktop */}
<button <button
onClick={onClose} 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" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
{/* Hero Image Section */} {/* Hero */}
{article.image_url && ( {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)]"> <div className="w-full h-[38vh] md:h-[44vh] relative overflow-hidden">
<img <img src={article.image_url} alt="" className="w-full h-full object-cover" />
src={article.image_url} <div className="absolute inset-0 bg-gradient-to-t from-carbon-light via-carbon-light/20 to-transparent" />
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> </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"> <header className="mb-10">
<div className="flex items-center space-x-3 text-nature text-[10px] uppercase tracking-[0.4em] font-black mb-8"> <div className="flex flex-wrap items-center gap-3 mb-6">
<span className="bg-nature/10 px-2 py-1 rounded">{article.feed_title}</span> <span className="chip">{article.feed_title}</span>
<span className="opacity-30">•</span> <span className="text-paper-muted/40">·</span>
<span>{article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Aujourd\'hui'}</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> </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} {article.title}
</h1> </h1>
{/* Smart Digest Section */} {/* Smart Digest */}
{aiSummary ? ( {aiSummary ? (
<div className="bg-nature/5 border-l-4 border-nature p-6 rounded-r-2xl mb-12 animate-fade-in shadow-sm"> <div className="bg-nature/5 border-l-4 border-nature p-6 rounded-r-2xl">
<h3 className="text-nature font-black text-[10px] uppercase tracking-[0.2em] mb-4 flex items-center"> <h2 className="eyebrow mb-3 flex items-center gap-2"><span>✨</span> Résumé IA</h2>
<span className="mr-2 text-base">✨</span> Résumé <p className="text-paper-white/90 text-lg leading-relaxed font-reading italic">{aiSummary}</p>
</h3>
<p className="text-paper-white text-lg leading-relaxed font-reading italic">
{aiSummary}
</p>
</div> </div>
) : ( ) : (
<div className="mb-12"> <button onClick={handleSummarize} disabled={isSummarizing} className="btn-secondary">
<button <span className={isSummarizing ? 'animate-spin' : ''}>{isSummarizing ? '⏳' : '✨'}</span>
onClick={async (e) => { {isSummarizing ? 'Génération…' : 'Générer le résumé'}
e.stopPropagation(); </button>
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>
)} )}
</header> </header>
<div <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 }} dangerouslySetInnerHTML={{ __html: displayContent }}
/> />
<div className="flex items-center justify-between py-8 border-y border-white/5 mb-16"> <div className="flex items-center justify-between py-6 border-y border-paper-muted/12">
<div className="flex items-center space-x-6"> <div className="flex items-center gap-3">
<ShareButton article={article} /> <ShareButton article={article} />
<button <button
onClick={() => onToggleFavorite(article.id)} 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 className={`flex items-center gap-2 px-5 py-2.5 rounded-full border text-[11px] uppercase tracking-[0.18em] font-bold transition-all ${
? 'bg-nature text-white border-paper-white/20 shadow-lg' article.is_favorite ? 'bg-earth text-white border-earth' : 'border-earth/30 text-earth hover:bg-earth/10'
: 'border-paper-white/20 text-nature hover:bg-nature/5' }`}
}`} 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" /> <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> </svg>
<span className="text-[10px] uppercase tracking-[0.2em] font-black"> {article.is_favorite ? 'Favori' : 'Ajouter'}
{article.is_favorite ? 'Dans vos favoris' : 'Ajouter aux favoris'}
</span>
</button> </button>
</div> </div>
<a {article.url && (
href={article.url} <a href={article.url} target="_blank" rel="noopener noreferrer"
target="_blank" className="group flex items-center gap-2 eyebrow text-paper-muted hover:text-nature transition-colors">
rel="noopener noreferrer" Source
className="group flex items-center space-x-2 text-[10px] uppercase tracking-[0.2em] font-black text-paper-muted hover:text-nature transition-colors" <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" />
<span>Source d'origine</span> </svg>
<svg className="w-3 h-3 transform group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </a>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" /> )}
</svg>
</a>
</div> </div>
<footer className="text-center pb-24"> <footer className="text-center pt-12">
<div className="text-nature text-4xl font-serif mb-6 italic select-none">F.</div> <div className="text-nature text-3xl font-serif italic select-none mb-3">F.</div>
<p className="text-paper-muted/40 text-[10px] uppercase tracking-[0.4em] font-black"> <p className="eyebrow text-paper-muted/40">FlowReader · Édition 2026</p>
FlowReader Lux Édition • 2026
</p>
</footer> </footer>
</div> </div>
</div> </motion.article>
{/* Mobile close button overlay */} {/* Mobile close */}
<button <button
onClick={onClose} 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </motion.div>
); );
} }
+155 -158
View File
@@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../stores/authStore';
import { authApi } from '../api/auth';
import { feedsApi } from '../api/feeds'; import { feedsApi } from '../api/feeds';
import { articlesApi } from '../api/articles'; import { articlesApi } from '../api/articles';
import { AddFeedModal } from './AddFeedModal'; import { AddFeedModal } from './AddFeedModal';
@@ -15,19 +17,13 @@ interface SidebarProps {
export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMode }: SidebarProps) { export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMode }: SidebarProps) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const logoutStore = useAuthStore((s) => s.logout);
const { data: feeds } = useQuery({ const { data: feeds } = useQuery({ queryKey: ['feeds'], queryFn: () => feedsApi.list() });
queryKey: ['feeds'],
queryFn: () => feedsApi.list(),
});
const markAllReadMutation = useMutation({ const markAllReadMutation = useMutation({
mutationFn: () => { mutationFn: () => (selectedFeedId ? articlesApi.markAllRead(selectedFeedId) : articlesApi.markAllReadGlobal()),
if (selectedFeedId) {
return articlesApi.markAllRead(selectedFeedId);
}
return articlesApi.markAllReadGlobal();
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] }); queryClient.invalidateQueries({ queryKey: ['feeds'] });
queryClient.invalidateQueries({ queryKey: ['articles'] }); queryClient.invalidateQueries({ queryKey: ['articles'] });
@@ -38,190 +34,191 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
mutationFn: (id: string) => feedsApi.delete(id), mutationFn: (id: string) => feedsApi.delete(id),
onSuccess: (_, deletedId) => { onSuccess: (_, deletedId) => {
queryClient.invalidateQueries({ queryKey: ['feeds'] }); queryClient.invalidateQueries({ queryKey: ['feeds'] });
if (selectedFeedId === deletedId) { if (selectedFeedId === deletedId) onSelectFeed(null);
onSelectFeed(null);
}
}, },
}); });
const updateFeedMutation = useMutation({ const updateFeedMutation = useMutation({
mutationFn: ({ id, title }: { id: string; title: string }) => feedsApi.update(id, title), mutationFn: ({ id, title }: { id: string; title: string }) => feedsApi.update(id, title),
onSuccess: () => { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['feeds'] }),
queryClient.invalidateQueries({ queryKey: ['feeds'] }); });
},
const logoutMutation = useMutation({
mutationFn: () => authApi.logout(),
onSuccess: () => { logoutStore(); window.location.href = '/login'; },
}); });
const handleDelete = (e: React.MouseEvent, id: string) => { const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation(); e.stopPropagation();
if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) { if (window.confirm('Voulez-vous vraiment supprimer ce flux ?')) deleteFeedMutation.mutate(id);
deleteFeedMutation.mutate(id);
}
}; };
const handleRename = (e: React.MouseEvent, id: string, currentTitle: string) => { const handleRename = (e: React.MouseEvent, id: string, currentTitle: string) => {
e.stopPropagation(); e.stopPropagation();
const newTitle = window.prompt('Renommer le flux :', currentTitle); const newTitle = window.prompt('Renommer le flux :', currentTitle);
if (newTitle && newTitle.trim() !== '' && newTitle !== 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 ( 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"> <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">
<div className="p-8 pb-4"> {/* Brand + actions */}
<div className="flex items-center justify-between mb-8"> <div className="p-6 pb-3">
<h1 <div className="flex items-center justify-between mb-7">
className="text-nature text-3xl font-serif italic tracking-tight cursor-pointer hover:opacity-80 transition-opacity" <button
style={{ textShadow: '1px 1px 2px rgba(100, 100, 100, 0.2)' }}
onClick={() => onSelectFeed(null)} onClick={() => onSelectFeed(null)}
className="text-nature text-2xl font-serif italic tracking-tight hover:opacity-80 transition-opacity"
> >
FlowReader FlowReader
</h1> </button>
<div className="flex items-center space-x-2"> <div className="flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
<button <button onClick={() => setIsAddModalOpen(true)} className="icon-btn" title="Ajouter un flux" aria-label="Ajouter un flux">
onClick={() => setIsAddModalOpen(true)} <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="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" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
title="Ajouter un flux" </svg>
>
<span className="text-xl leading-none" style={{ marginBottom: '1px' }}>+</span>
</button> </button>
</div> </div>
</div> </div>
<nav className="space-y-1"> <button
<button onClick={() => markAllReadMutation.mutate()}
onClick={() => markAllReadMutation.mutate()} disabled={markAllReadMutation.isPending}
disabled={markAllReadMutation.isPending} className="btn-secondary w-full"
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 title="Tout marquer comme lu"
? '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' <svg className={`w-4 h-4 ${markAllReadMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
}`} {markAllReadMutation.isPending
title="Tout marquer comme lu" ? <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 className={`w-4 h-4 ${markAllReadMutation.isPending ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"> </svg>
{markAllReadMutation.isPending {markAllReadMutation.isPending ? 'Traitement…' : 'Tout marquer lu'}
? <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" /> </button>
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" /> </div>
}
</svg> {/* Nav */}
<span className="text-xs uppercase tracking-[0.2em] font-bold"> <nav className="flex-1 overflow-y-auto px-3 py-3 space-y-1">
{markAllReadMutation.isPending ? 'Traitement...' : 'Tout marquer lu'} <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> </span>
</button> </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>
<div className="p-6 border-t border-white/5"> <div className="group/item">
<div className="p-4 rounded-xl bg-nature/5 border border-nature/10"> <button onClick={() => onSelectFeed('favorites')} className={navItem(selectedFeedId === 'favorites')}>
<p className="text-[10px] text-nature/60 uppercase tracking-widest font-bold mb-1">Mode Zen</p> <span className="text-sm font-medium flex items-center truncate">
<p className="text-[9px] text-paper-muted leading-relaxed italic">"Le silence est le bruit des esprits."</p> <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>
</div> </div>
</aside> </aside>
<AddFeedModal <AddFeedModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} />
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
/>
</> </>
); );
} }
+35 -21
View File
@@ -1,37 +1,51 @@
import { useEffect, useState } from 'react'; 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() { export function ThemeToggle() {
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light'); const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => { useEffect(() => {
const root = window.document.documentElement; const root = window.document.documentElement;
if (theme === 'dark') { root.classList.toggle('dark', theme === 'dark');
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
}, [theme]); }, [theme]);
const toggleTheme = () => { const isDark = theme === 'dark';
setTheme(theme === 'light' ? 'dark' : 'light');
};
return ( return (
<button <button
onClick={toggleTheme} onClick={() => setTheme(isDark ? 'light' : 'dark')}
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" className="icon-btn"
title={theme === 'light' ? 'Activer le mode sombre' : 'Activer le mode clair'} 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' ? ( <motion.span
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> key={theme}
<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" /> initial={{ rotate: -90, opacity: 0, scale: 0.5 }}
</svg> animate={{ rotate: 0, opacity: 1, scale: 1 }}
) : ( transition={{ duration: 0.3 }}
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> className="flex"
<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> {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> </button>
); );
} }
+23 -42
View File
@@ -6,75 +6,56 @@ interface FocusCardProps {
} }
export function FocusCard({ article, isTop = false }: FocusCardProps) { export function FocusCard({ article, isTop = false }: FocusCardProps) {
const timeToRead = article.content const timeToRead = article.content ? Math.max(1, Math.ceil(article.content.length / 1000)) : 1;
? Math.ceil(article.content.length / 1000) const excerpt = article.summary ? article.summary.replace(/<[^>]*>?/gm, '').slice(0, 150) : '';
: 1;
return ( 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"> <article className="w-full h-full bg-carbon-light rounded-3xl overflow-hidden flex flex-col relative select-none cursor-grab active:cursor-grabbing">
{/* Image Section (Top 60%) */} {/* Cover (60%) */}
<div className="h-[60%] relative bg-carbon-light overflow-hidden"> <div className="h-[58%] relative overflow-hidden">
{article.image_url ? ( {article.image_url ? (
<div <div className="w-full h-full bg-cover bg-center" style={{ backgroundImage: `url(${article.image_url})` }} />
className="w-full h-full bg-cover bg-center transition-transform duration-700"
style={{ backgroundImage: `url(${article.image_url})` }}
/>
) : ( ) : (
/* Fallback Gradient if no image */
<div className="w-full h-full bg-gradient-to-br from-nature to-nature-light p-8 flex items-end"> <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} {article.title}
</h1> </h2>
</div> </div>
)} )}
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-black/55 to-transparent pointer-events-none" />
{/* Overlay Gradient for text readability */} <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">
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/60 to-transparent pointer-events-none" />
{/* Badge: Time to Read */}
<div className="absolute top-4 right-4 bg-black/40 backdrop-blur-md text-white text-[10px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full border border-white/10 shadow-lg">
~{timeToRead} min ~{timeToRead} min
</div> </span>
</div> </div>
{/* Content Section (Bottom 40%) */} {/* Body (40%) */}
<div className="flex-1 p-6 flex flex-col bg-white relative"> <div className="flex-1 p-6 flex flex-col">
{/* Source & Date */} <div className="flex items-center justify-between mb-3 text-[10px] uppercase tracking-widest font-bold text-paper-muted/70">
<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 text-nature">
<span className="flex items-center gap-2"> <span className="w-1.5 h-1.5 rounded-full bg-nature" />
<span className="w-1.5 h-1.5 rounded-full bg-nature"></span> {article.feed_title || 'Journal'}
<span className="text-nature/80">
{article.feed_title || 'Journal'}
</span>
</span> </span>
<span> <span>
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', { {new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
weekday: 'short', weekday: 'short', hour: '2-digit', minute: '2-digit',
hour: '2-digit',
minute: '2-digit'
})} })}
</span> </span>
</div> </div>
{/* Title (if image present, otherwise shown in gradient header) */}
{article.image_url && ( {article.image_url && (
<h2 className="text-xl sm:text-2xl font-serif italic text-paper-white leading-tight mb-3 line-clamp-2"> <h2 className="text-xl sm:text-2xl font-serif italic text-paper-white leading-tight mb-3 line-clamp-2">
{article.title} {article.title}
</h2> </h2>
)} )}
{/* Excerpt */} {excerpt && (
{article.summary && ( <p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading">{excerpt}…</p>
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-nature/20">
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 140)}...
</p>
)} )}
{/* Hint (only on top card) */}
{isTop && ( {isTop && (
<div className="mt-auto pt-4 text-center opacity-40 text-[10px] uppercase tracking-widest font-bold text-paper-muted animate-pulse"> <p className="mt-auto pt-4 text-center text-[10px] uppercase tracking-widest font-bold text-paper-muted/40 animate-pulse">
Double-cliquer pour lire • Glisser pour trier Double-clic pour lire · Glisser pour trier
</div> </p>
)} )}
</div> </div>
</article> </article>
+64 -190
View File
@@ -15,10 +15,6 @@ interface FocusCardStackProps {
onEmpty: () => void; onEmpty: () => void;
} }
// ------------------------------------------------------------------
// Internal Component: SwipeableCard
// Isolates drag state per card to prevent "rebound" on unmount.
// ------------------------------------------------------------------
interface SwipeableCardProps { interface SwipeableCardProps {
article: Article; article: Article;
index: number; index: number;
@@ -29,144 +25,58 @@ interface SwipeableCardProps {
isProcessing: boolean; 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) { function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef, isProcessing }: SwipeableCardProps) {
const x = useMotionValue(0); const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-10, 10]); 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 xSpring = useSpring(x, { stiffness: 1000, damping: 50 });
const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 }); const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 });
const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => { const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => {
if (!isTop || isProcessing) return; // Only top card is draggable if (!isTop || isProcessing) return;
if (active && Math.abs(mx) > 5) hasDraggedRef.current = true;
if (active && Math.abs(mx) > 5) {
hasDraggedRef.current = true;
}
const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50); const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50);
if (!active && trigger) { if (!active && trigger) {
// Swipe Validated onAction(mx > 0 ? 'read' : 'keep');
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');
} else if (!active) { } else if (!active) {
// Snap Back
x.set(0); x.set(0);
setTimeout(() => { hasDraggedRef.current = false; }, 50); setTimeout(() => { hasDraggedRef.current = false; }, 50);
} else { } else {
// Dragging
x.set(mx); x.set(mx);
} }
}); });
// Determine zIndex and scale based on index
const zIndex = 100 - index;
const scale = 1 - index * 0.05; const scale = 1 - index * 0.05;
const yOffset = index * 20; const yOffset = index * 18;
return ( return (
<motion.div <motion.div
className="absolute inset-0 rounded-3xl shadow-2xl origin-bottom" className="absolute inset-0 rounded-3xl origin-bottom"
style={{ 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, x: isTop ? xSpring : 0,
rotate: isTop ? rotateSpring : 0, rotate: isTop ? rotateSpring : 0,
zIndex: zIndex, zIndex: 100 - index,
cursor: isTop ? 'grab' : 'default', cursor: isTop ? 'grab' : 'default',
boxShadow: 'var(--shadow-float)',
}} }}
{...(isTop ? (bind() as any) : {})} // Bind gesture only if top {...(isTop ? (bind() as Record<string, unknown>) : {})}
onDoubleClick={() => { onDoubleClick={() => { if (isTop && !hasDraggedRef.current) onOpen(); }}
if (isTop && !hasDraggedRef.current) { initial={{ scale, y: yOffset, x: 0 }}
onOpen(); animate={{ scale, y: yOffset, x: 0 }}
} exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
}}
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 }
}}
> >
{/* Visual Indicators embedded in card to move with it? {/* Tinder-style stamps */}
Actually original code had them in background. <motion.div style={{ opacity: opacityRead }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-nature/15">
If we want them ON the card, we put them here. <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">
If in background, parent handles. J'ai lu
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
</div> </div>
</motion.div> </motion.div>
<motion.div style={{ opacity: opacityKeep }} className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-3xl bg-earth/15">
<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-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">
<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">
Garder Garder
</div> </div>
</motion.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) { export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, onEmpty }: FocusCardStackProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [readingArticle, setReadingArticle] = useState<Article | null>(null); const [readingArticle, setReadingArticle] = useState<Article | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [isProcessing, setIsProcessing] = useState(false); 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); const hasDraggedRef = useRef(false);
// Visible stack size
const visibleArticles = articles.slice(currentIndex, currentIndex + 3); const visibleArticles = articles.slice(currentIndex, currentIndex + 3);
const topArticle = visibleArticles[0]; const topArticle = visibleArticles[0];
@@ -199,38 +101,25 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
setIsProcessing(true); setIsProcessing(true);
if (action === 'undo') { if (action === 'undo') {
if (currentIndex > 0) { setCurrentIndex((prev) => Math.max(0, prev - 1));
setCurrentIndex(prev => Math.max(0, prev - 1));
}
} else if (action === 'read') { } else if (action === 'read') {
onMarkRead(topArticle.id); if (topArticle) onMarkRead(topArticle.id);
setCurrentIndex(prev => prev + 1); setCurrentIndex((prev) => prev + 1);
} else { } else {
onKeep(topArticle.id); if (topArticle) onKeep(topArticle.id);
setCurrentIndex(prev => prev + 1); setCurrentIndex((prev) => prev + 1);
} }
setTimeout(() => setIsProcessing(false), 450);
// Lock for animation duration
setTimeout(() => setIsProcessing(false), 500);
}; };
// Callbacks for the card components
const handleCardAction = (action: 'read' | 'keep') => {
handleAction(action);
};
// Check for empty stack
useEffect(() => { useEffect(() => {
if (currentIndex >= articles.length && articles.length > 0) { if (currentIndex >= articles.length && articles.length > 0) onEmpty();
onEmpty();
}
}, [currentIndex, articles.length, onEmpty]); }, [currentIndex, articles.length, onEmpty]);
if (!topArticle) return null; if (!topArticle) return null;
return ( return (
<div className="relative w-full h-full flex items-center justify-center overflow-hidden"> <div className="relative w-full h-full flex items-center justify-center overflow-hidden">
{/* Reading View Modal */}
<AnimatePresence> <AnimatePresence>
{readingArticle && ( {readingArticle && (
isMobile ? ( isMobile ? (
@@ -238,93 +127,78 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite,
article={readingArticle} article={readingArticle}
onClose={() => setReadingArticle(null)} onClose={() => setReadingArticle(null)}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onNext={() => { onNext={() => { handleAction('read'); setReadingArticle(null); }}
handleAction('read'); onPrev={() => { handleAction('keep'); setReadingArticle(null); }}
setReadingArticle(null);
}}
onPrev={() => {
handleAction('keep');
setReadingArticle(null);
}}
/> />
) : ( ) : (
<ReaderView <ReaderView article={readingArticle} onClose={() => setReadingArticle(null)} onToggleFavorite={onToggleFavorite} />
article={readingArticle}
onClose={() => setReadingArticle(null)}
onToggleFavorite={onToggleFavorite}
/>
) )
)} )}
</AnimatePresence> </AnimatePresence>
{/* Note: Background indicators removed in favor of Card Overlays for isolated state cleanliness */} {/* Stack */}
{/* Card Stack */}
<div className="relative w-full max-w-lg aspect-[3/4] sm:aspect-[4/5] md:h-[600px] z-10"> <div className="relative w-full max-w-lg aspect-[3/4] sm:aspect-[4/5] md:h-[600px] z-10">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{visibleArticles.map((article, i) => { {visibleArticles.map((article, i) => (
return ( <SwipeableCard
<SwipeableCard key={article.id}
key={article.id} article={article}
article={article} index={i}
index={i} isTop={i === 0}
isTop={i === 0} onAction={handleAction}
onAction={handleCardAction} onOpen={() => setReadingArticle(article)}
onOpen={() => setReadingArticle(article)} hasDraggedRef={hasDraggedRef}
hasDraggedRef={hasDraggedRef} isProcessing={isProcessing}
isProcessing={isProcessing} />
/> ))}
);
})}
</AnimatePresence> </AnimatePresence>
</div> </div>
<div className="absolute bottom-10 left-0 right-0 flex justify-center items-center gap-4 px-4 sm:gap-8 z-20"> {/* Controls */}
{/* Garder */} <div className="absolute bottom-10 left-0 right-0 flex justify-center items-center gap-4 sm:gap-8 px-4 z-20">
<button <button
onClick={() => handleAction('keep')} onClick={() => handleAction('keep')}
disabled={isProcessing} 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" 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)" title="Garder (gauche)" aria-label="Garder"
> >
<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 15l7-7 7 7" transform="rotate(-90 12 12)" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg> </svg>
</button> </button>
{/* Undo */}
<button <button
onClick={() => handleAction('undo')} onClick={() => handleAction('undo')}
disabled={currentIndex === 0 || isProcessing} disabled={currentIndex === 0 || isProcessing}
className={`p-3 rounded-full border shadow-lg transition-all duration-300 ${currentIndex > 0 className={`p-3 rounded-full border shadow-lg transition-all ${
? 'bg-white border-nature/10 text-nature hover:bg-nature hover:text-white hover:border-nature hover:scale-105 opacity-100 cursor-pointer' currentIndex > 0
: 'bg-white/5 border-transparent text-transparent opacity-0 pointer-events-none scale-90' ? '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="Revenir en arrière" }`}
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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg> </svg>
</button> </button>
{/* J'ai lu */}
<button <button
onClick={() => handleAction('read')} onClick={() => handleAction('read')}
disabled={isProcessing} 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" 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)" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
</button> </button>
</div> </div>
{/* Progress Counter */} {/* Counter */}
<div className="absolute top-10 left-0 right-0 text-center z-20 pointer-events-none"> <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"> <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 {articles.length - currentIndex} restants
</div> </span>
</div> </div>
</div> </div>
); );
+14 -12
View File
@@ -1,30 +1,32 @@
import { motion } from 'framer-motion';
interface FocusEmptyStateProps { interface FocusEmptyStateProps {
onBack: () => void; onBack: () => void;
} }
export function FocusEmptyState({ onBack }: FocusEmptyStateProps) { export function FocusEmptyState({ onBack }: FocusEmptyStateProps) {
return ( 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="relative">
<div className="w-32 h-32 rounded-full bg-nature/10 flex items-center justify-center animate-bounce-slow"> <div className="w-28 h-28 rounded-full bg-nature/10 flex items-center justify-center animate-bounce-slow">
<span className="text-6xl">🎉</span> <span className="text-5xl">🌿</span>
</div> </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> </div>
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-4xl font-serif italic text-paper-white">C'est tout pour le moment !</h1> <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> </div>
<button <button onClick={onBack} className="btn-primary py-3 px-8">Retour au tableau de bord</button>
onClick={onBack} </motion.div>
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>
); );
} }
+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"; @import "tailwindcss";
/* ============================================================
FlowReader — Design System "Éditorial Botanique"
Light: Organic Warmth · Dark: Deep Forest
============================================================ */
@theme { @theme {
--color-carbon: #F9F7F1; /* Surfaces — warm paper */
--color-carbon-light: #FFFFFF; --color-carbon: #F7F4EC; /* app background */
--color-carbon-dark: #E7E5E4; --color-carbon-light: #FFFFFF; /* raised surface / cards */
--color-nature: #166534; --color-carbon-dark: #E9E4D8; /* borders / sunken */
--color-nature-light: #15803d;
--color-nature-dark: #0A0F0B;
/* Deep Forest Base */
--color-earth: #A0522D;
--color-paper-white: #1C1917;
--color-paper-muted: #57534E;
--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-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 { .dark {
--color-carbon: #0A0F0B; --color-carbon: #0A0F0B;
--color-carbon-light: #141C16; --color-carbon-light: #12190F;
--color-carbon-dark: #050705; --color-carbon-dark: #060A06;
--color-nature: #22C55E;
--color-nature-light: #4ADE80; --color-nature: #34D27B;
--color-paper-white: #F9F7F1; --color-nature-light: #5DE89A;
--color-paper-muted: #A8A29E; --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 { @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 { .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 { @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); background: var(--color-carbon-light);
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid color-mix(in oklab, var(--color-paper-muted) 16%, transparent);
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-soft);
} }
@utility text-balance {
text-wrap: balance;
}
/* ============================================================
Base
============================================================ */
@layer 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 { 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; margin: 0;
min-height: 100vh; min-height: 100vh;
border-top: 4px solid var(--color-nature); border-top: 3px solid var(--color-nature);
/* Editorial Touch */ background-image:
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"); 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-blend-mode: multiply;
background-repeat: repeat; background-repeat: no-repeat, repeat;
transition: background-color 0.5s ease, color 0.5s ease; transition: background-color 0.4s ease, color 0.4s ease;
} }
.dark body { .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-image:
background-blend-mode: screen; 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, h1, h2, h3, h4, h5, h6 {
h2,
h3,
h4,
h5,
h6 {
@apply font-serif tracking-tight leading-tight text-paper-white; @apply font-serif tracking-tight leading-tight text-paper-white;
} }
a { a {
@apply transition-all duration-300 decoration-nature/30 underline-offset-4 hover:decoration-nature; @apply transition-colors duration-300;
} }
::-webkit-scrollbar { /* Accessible focus — keyboard only */
width: 6px; :focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
border-radius: var(--radius-xs);
} }
:focus:not(:focus-visible) {
::-webkit-scrollbar-track { outline: none;
background: transparent;
} }
/* Scrollbars */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(87, 83, 78, 0.2); background: color-mix(in oklab, var(--color-paper-muted) 28%, transparent);
border-radius: 10px; border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--color-paper-muted) 45%, transparent);
} }
::-webkit-scrollbar-thumb:hover { /* Respect reduced motion */
background: rgba(87, 83, 78, 0.4); @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 { @layer components {
.magazine-grid { /* Eyebrow / kicker label */
display: grid; .eyebrow {
grid-template-columns: repeat(1, minmax(0, 1fr)); @apply text-[10px] uppercase tracking-[0.35em] font-bold text-nature;
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;
} }
/* Buttons */
.btn-primary { .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 { .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 { .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 { /* Chip / badge */
@apply bg-carbon-light border-white/5 text-paper-white placeholder:text-paper-muted/30; .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 { .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-7; }
.magazine-content p {
@apply mb-8;
}
.magazine-content a { .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 h2,
.magazine-content h3 { .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 { .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 ul,
.magazine-content ol { .magazine-content ol { @apply mb-7 ml-6 space-y-3; }
@apply mb-8 ml-6 space-y-4; .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 { /* Drop cap for immersive reader */
@apply pl-2 marker:text-nature; .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 { @layer utilities {
.animate-bounce-slow { .text-shadow-sm { text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); }
animation: bounce-slow 3s infinite;
}
.animate-fade-in { .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
animation: fade-in 0.5s ease-out forwards; .no-scrollbar::-webkit-scrollbar { display: none; }
}
.text-shadow-sm { .animate-fade-in { animation: fade-in 0.5s ease-out forwards; }
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); .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 bounce-slow {
0%,
100% {
transform: translateY(-5%);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: translateY(0);
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
@keyframes fade-in { @keyframes fade-in {
from { from { opacity: 0; transform: translateY(8px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@keyframes rise {
.custom-scrollbar::-webkit-scrollbar { from { opacity: 0; transform: translateY(20px); }
width: 8px; to { opacity: 1; transform: translateY(0); }
} }
@keyframes bounce-slow {
.custom-scrollbar::-webkit-scrollbar-track { 0%, 100% { transform: translateY(-5%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); }
background: transparent; 50% { transform: translateY(0); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); }
} }
@keyframes shake {
.custom-scrollbar::-webkit-scrollbar-thumb { 0%, 100% { transform: translateX(0); }
background: rgba(0, 0, 0, 0.1); 25% { transform: translateX(-5px); }
border-radius: 4px; 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 { useState } from 'react';
import { Sidebar } from '../components/Sidebar'; import { Sidebar } from '../components/Sidebar';
import { MobileTopBar } from '../components/MobileTopBar';
import { DashboardPage } from '../pages/DashboardPage'; import { DashboardPage } from '../pages/DashboardPage';
import { FocusPage } from '../pages/FocusPage'; import { FocusPage } from '../pages/FocusPage';
@@ -15,13 +16,18 @@ export function RootLayout() {
onEnterFocus={() => setIsFocusMode(true)} onEnterFocus={() => setIsFocusMode(true)}
isFocusMode={isFocusMode} isFocusMode={isFocusMode}
/> />
{isFocusMode ? ( {isFocusMode ? (
<FocusPage onExit={() => setIsFocusMode(false)} /> <FocusPage onExit={() => setIsFocusMode(false)} />
) : ( ) : (
<DashboardPage <div className="flex-1 flex flex-col min-w-0">
selectedFeedId={selectedFeedId} <MobileTopBar
onEnterFocus={() => setIsFocusMode(true)} onSelectFeed={setSelectedFeedId}
/> selectedFeedId={selectedFeedId}
onEnterFocus={() => setIsFocusMode(true)}
/>
<DashboardPage selectedFeedId={selectedFeedId} onEnterFocus={() => setIsFocusMode(true)} />
</div>
)} )}
</div> </div>
); );
+143 -235
View File
@@ -6,34 +6,22 @@ import { feedsApi } from '../api/feeds';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { ReaderView } from '../components/ReaderView'; import { ReaderView } from '../components/ReaderView';
import { MobileReaderView } from '../components/MobileReaderView'; import { MobileReaderView } from '../components/MobileReaderView';
import { useIsMobile } from '../hooks/useIsMobile';
import { useWebsocket } from '../hooks/useWebsocket'; import { useWebsocket } from '../hooks/useWebsocket';
import type { Article } from '../api/articles'; import type { Article } from '../api/articles';
const breakpointColumnsObj = { const breakpointColumnsObj = { default: 3, 1100: 2, 700: 1 };
default: 3, const ITEMS_PER_PAGE = 24;
1100: 2,
700: 1
};
interface DashboardPageProps { interface DashboardPageProps {
selectedFeedId: string | null; selectedFeedId: string | null;
onEnterFocus: () => void; onEnterFocus: () => void;
} }
const ITEMS_PER_PAGE = 24; type ArticlePages = { pages: Article[][] };
// Helper Hook for Responsive Split function timestamp(a: Article): number {
function useIsMobile() { return new Date(a.published_at || a.created_at).getTime();
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;
} }
export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) { export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPageProps) {
@@ -43,118 +31,86 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
const [debouncedQuery, setDebouncedQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState('');
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const loadMoreRef = useRef<HTMLDivElement>(null); const loadMoreRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLElement>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
// Debounce search query
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => setDebouncedQuery(searchQuery), 400);
setDebouncedQuery(searchQuery);
}, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery]); }, [searchQuery]);
// Real-time sync
useWebsocket(); useWebsocket();
const { const queryKey = ['articles', selectedFeedId, debouncedQuery];
data,
isLoading, const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
fetchNextPage, queryKey,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['articles', selectedFeedId, debouncedQuery],
queryFn: ({ pageParam = 0 }) => { queryFn: ({ pageParam = 0 }) => {
const options = { const options = { limit: ITEMS_PER_PAGE, offset: pageParam };
limit: ITEMS_PER_PAGE, if (debouncedQuery) return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam);
offset: pageParam, if (selectedFeedId === 'favorites') return articlesApi.list({ ...options, favorite: true });
};
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 }); return articlesApi.list({ ...options, feed_id: selectedFeedId || undefined });
}, },
initialPageParam: 0, initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => { getNextPageParam: (lastPage, allPages) =>
if (lastPage.length < ITEMS_PER_PAGE) return undefined; lastPage.length < ITEMS_PER_PAGE ? undefined : allPages.length * ITEMS_PER_PAGE,
return allPages.length * ITEMS_PER_PAGE;
},
}); });
const articles = data?.pages.flat() || [];
const sortedArticles = useMemo(() => { const sortedArticles = useMemo(() => {
return [...articles].sort((a, b) => { const all = data?.pages.flat() ?? [];
// First, sort by read status: unread (false) first return [...all].sort((a, b) => {
if (a.is_read !== b.is_read) { if (a.is_read !== b.is_read) return a.is_read ? 1 : -1;
return a.is_read ? 1 : -1; return timestamp(b) - timestamp(a);
}
// 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);
}); });
}, [articles]); }, [data]);
// Intersection Observer for Infinite Scroll const unreadCount = useMemo(() => sortedArticles.filter((a) => !a.is_read).length, [sortedArticles]);
// Infinite scroll
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
fetchNextPage();
}
}, },
{ threshold: 0.1 } { threshold: 0.1 }
); );
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect(); return () => observer.disconnect();
}, [hasNextPage, fetchNextPage, isFetchingNextPage]); }, [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({ const refreshMutation = useMutation({
mutationFn: () => feedsApi.refresh(), mutationFn: () => feedsApi.refresh(),
onSuccess: () => { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
}); });
const toggleReadMutation = useMutation({ const toggleReadMutation = useMutation({
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) => mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id), is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
onMutate: async ({ id, is_read }) => { onMutate: async ({ id, is_read }) => {
const queryKey = ['articles', selectedFeedId, debouncedQuery];
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey); const previousData = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old: ArticlePages | undefined) => {
queryClient.setQueryData(queryKey, (old: any) => {
if (!old) return old; if (!old) return old;
return { return {
...old, ...old,
pages: old.pages.map((page: Article[]) => pages: old.pages.map((page) => page.map((a) => (a.id === id ? { ...a, is_read } : a))),
page.map((article) =>
article.id === id ? { ...article, is_read: is_read } : article
)
),
}; };
}); });
return { previousData }; return { previousData };
}, },
onError: (_err, _newTodo, context) => { onError: (_e, _v, context) => {
const queryKey = ['articles', selectedFeedId, debouncedQuery]; if (context?.previousData) queryClient.setQueryData(queryKey, context.previousData);
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
}, },
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
}); });
const toggleFavoriteMutation = useMutation({ const toggleFavoriteMutation = useMutation({
@@ -162,156 +118,116 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
}); });
const [showScrollTop, setShowScrollTop] = useState(false); const heading = debouncedQuery
const mainRef = useRef<HTMLElement>(null); ? `Résultats : « ${debouncedQuery} »`
: selectedFeedId === 'favorites'
// Visibility logic for "Back to Top" button ? 'Mes favoris'
useEffect(() => { : selectedFeedId
const handleScroll = (e: Event) => { ? 'Archives du flux'
const target = e.target as HTMLElement; : 'La Une';
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' });
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center bg-carbon space-y-4"> <div className="flex-1 flex flex-col items-center justify-center gap-4 bg-carbon">
<div className="w-12 h-12 border-t-2 border-nature rounded-full animate-spin"></div> <div className="w-11 h-11 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
<p className="text-paper-muted text-[10px] uppercase tracking-[0.4em] font-black">Composition de votre édition...</p> <p className="eyebrow text-paper-muted">Composition de votre édition…</p>
</div> </div>
); );
} }
return ( return (
<main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon scroll-smooth relative"> <main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon relative">
{/* Back to Top Button */} {/* Back to top */}
<button <button
onClick={scrollToTop} onClick={() => mainRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
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'}`} 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" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg> </svg>
</button> </button>
<div className="max-w-[1400px] mx-auto px-12 py-16"> <div className="max-w-[1400px] mx-auto px-6 md:px-12 py-10 md:py-14">
{/* Header */} {/* Masthead */}
<header className="mb-20 space-y-12 border-b border-white/5 pb-10"> <header className="mb-12 border-b border-paper-muted/15 pb-8">
<div className="flex items-start justify-between"> <div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
<div className="space-y-1"> <div>
<p className="text-nature text-[10px] uppercase tracking-[0.5em] font-black">Édition du jour</p> <p className="eyebrow mb-2">Édition du jour</p>
<h1 className="text-6xl font-serif italic text-paper-white tracking-tighter"> <h1 className="text-5xl md:text-6xl font-serif italic text-paper-white tracking-tight text-balance">
{debouncedQuery ? `Résultats : "${debouncedQuery}"` : selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'} {heading}
</h1> </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>
<div className="flex flex-col items-end gap-6">
<div className="flex flex-col items-end text-right"> <div className="flex items-center gap-3">
<p className="text-nature text-sm font-serif italic font-bold tracking-wide"> <div className="relative group">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} <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">
</p> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<p className="text-[10px] text-paper-muted opacity-80 uppercase tracking-[0.25em] font-black mt-1"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
Volume IX • No. 42 </svg>
</p> </span>
</div> <input
<div className="flex items-center gap-3"> type="search"
<div className="relative group"> aria-label="Rechercher dans l'édition"
<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"> placeholder="Rechercher…"
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> value={searchQuery}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> onChange={(e) => setSearchQuery(e.target.value)}
</svg> className="input-field pl-10 pr-4 py-2.5 rounded-full w-44 focus:w-64 transition-all text-sm"
</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> </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>
</div> </div>
</header> </header>
{/* Focus Mode CTA Widget - Compact Version */} {/* Focus CTA */}
{!selectedFeedId && sortedArticles && sortedArticles.length > 0 && ( {!selectedFeedId && !debouncedQuery && unreadCount > 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}> <button
{/* decorative circles */} onClick={onEnterFocus}
<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> 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]"
<div className="absolute -bottom-12 -left-12 w-48 h-48 bg-black/10 rounded-full blur-2xl pointer-events-none"></div> >
<span className="absolute -top-12 -right-12 w-48 h-48 bg-white/10 rounded-full blur-2xl pointer-events-none" />
<div className="flex-1 z-10 text-center md:text-left mb-4 md:mb-0"> <span className="relative z-10">
<div className="flex items-center justify-center md:justify-start space-x-2 mb-1"> <span className="text-[10px] uppercase tracking-[0.3em] font-bold text-white/70">Mode Focus</span>
<span className="bg-white/20 text-white text-[9px] font-bold px-1.5 py-0.5 rounded uppercase tracking-widest backdrop-blur-sm"> <span className="block text-2xl font-serif italic mt-1">Votre session de lecture</span>
Nouveauté <span className="block text-white/80 text-sm mt-1">{unreadCount} articles non lus à trier d'un geste.</span>
</span> </span>
</div> <span className="relative z-10 btn-primary bg-white !text-nature group-hover:!bg-white/90">
<h2 className="text-2xl font-serif italic text-white mb-1 text-shadow-sm"> Lancer
Votre session de lecture <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
</h2> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
<p className="text-white/80 font-medium text-xs max-w-lg"> </svg>
{sortedArticles.filter(a => !a.is_read).length} articles non lus. Passez en mode Focus. </span>
</p> </button>
</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>
)} )}
{/* Grid */} {/* Grid */}
{sortedArticles.length > 0 ? ( {sortedArticles.length > 0 ? (
<> <>
<Masonry <Masonry breakpointCols={breakpointColumnsObj} className="flex w-auto -ml-6" columnClassName="pl-6 bg-clip-padding">
breakpointCols={breakpointColumnsObj} {sortedArticles.map((article) => (
className="flex w-auto -ml-6" <div key={article.id} className="mb-6">
columnClassName="pl-6 bg-clip-padding"
>
{sortedArticles.map((article, index) => (
<div key={`${article.id}-${index}`} className="mb-6">
<ArticleCard <ArticleCard
article={article} article={article}
onClick={(article) => { onClick={(a) => {
setSelectedArticle(article); setSelectedArticle(a);
if (!article.is_read) { if (!a.is_read) toggleReadMutation.mutate({ id: a.id, is_read: true });
toggleReadMutation.mutate({ id: article.id, is_read: true });
}
}} }}
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })} onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
@@ -320,31 +236,30 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
))} ))}
</Masonry> </Masonry>
{/* Infinite Scroll Sentinel */} <div ref={loadMoreRef} className="mt-16 py-8 flex flex-col items-center gap-3">
<div ref={loadMoreRef} className="mt-20 py-10 flex flex-col items-center justify-center space-y-4">
{isFetchingNextPage ? ( {isFetchingNextPage ? (
<> <>
<div className="w-8 h-8 border-t-2 border-nature rounded-full animate-spin"></div> <div className="w-7 h-7 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
<p className="text-nature text-[8px] uppercase tracking-[0.4em] font-black">Chargement supplémentaire...</p> <p className="eyebrow text-paper-muted/60">Chargement…</p>
</> </>
) : hasNextPage ? ( ) : 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="flex items-center gap-3">
<div className="h-[1px] w-20 bg-nature/20"></div> <span className="h-px w-16 bg-nature/20" />
<p className="text-paper-muted/40 text-[8px] uppercase tracking-[0.4em] font-black">Fin de votre édition</p> <p className="eyebrow text-paper-muted/50">Fin de l'édition</p>
<div className="h-[1px] w-20 bg-nature/20"></div> <span className="h-px w-16 bg-nature/20" />
</div> </div>
)} )}
</div> </div>
</> </>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-40 border-2 border-dashed border-white/5 rounded-3xl"> <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-4"> <p className="text-paper-muted font-serif italic text-xl mb-2">
{debouncedQuery ? `Aucun article ne correspond à "${debouncedQuery}".` : 'Votre bibliothèque est vide.'} {debouncedQuery ? `Aucun article pour « ${debouncedQuery} ».` : 'Votre bibliothèque est vide.'}
</p> </p>
<p className="text-paper-muted/40 text-[10px] uppercase tracking-widest font-bold"> <p className="eyebrow text-paper-muted/50">
{debouncedQuery ? "Tentez une recherche plus large." : "Ajoutez un flux pour commencer votre lecture."} {debouncedQuery ? 'Tentez une recherche plus large.' : 'Ajoutez un flux pour commencer.'}
</p> </p>
</div> </div>
)} )}
@@ -357,14 +272,11 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
onClose={() => setSelectedArticle(null)} onClose={() => setSelectedArticle(null)}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
onNext={() => { 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) { if (idx < sortedArticles.length - 1) {
const nextArticle = sortedArticles[idx + 1]; const next = sortedArticles[idx + 1];
setSelectedArticle(nextArticle); setSelectedArticle(next);
if (!nextArticle.is_read) { if (!next.is_read) toggleReadMutation.mutate({ id: next.id, is_read: true });
toggleReadMutation.mutate({ id: nextArticle.id, is_read: true });
}
} else if (hasNextPage) { } else if (hasNextPage) {
fetchNextPage(); fetchNextPage();
} else { } else {
@@ -372,12 +284,8 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
} }
}} }}
onPrev={() => { onPrev={() => {
if (!sortedArticles) return; const idx = sortedArticles.findIndex((a) => a.id === selectedArticle.id);
const idx = sortedArticles.findIndex(a => a.id === selectedArticle.id); if (idx > 0) setSelectedArticle(sortedArticles[idx - 1]);
if (idx > 0) {
const prevArticle = sortedArticles[idx - 1];
setSelectedArticle(prevArticle);
}
}} }}
/> />
) : ( ) : (
+35 -69
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 { FocusCardStack } from '../components/focus/FocusCardStack';
import { FocusEmptyState } from '../components/focus/FocusEmptyState'; import { FocusEmptyState } from '../components/focus/FocusEmptyState';
@@ -12,102 +12,68 @@ export function FocusPage({ onExit }: FocusPageProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isComplete, setIsComplete] = useState(false); const [isComplete, setIsComplete] = useState(false);
// Fetch ONLY unread articles
const { data: articles, isLoading } = useQuery({ const { data: articles, isLoading } = useQuery({
queryKey: ['articles', 'focus-mode'], queryKey: ['articles', 'focus-mode'],
queryFn: () => articlesApi.list({ unread: true, limit: 100 }), // Cap at 100 for performance queryFn: () => articlesApi.list({ unread: true, limit: 100 }),
staleTime: 0, // Always fresh for focus mode staleTime: 0,
}); });
// Stable deck state to prevent UI jumping when articles are marked read // Snapshot the unread set once into a stable deck for the whole session,
const [deck, setDeck] = useState<typeof articles>([]); // so marking articles read mid-session never reshuffles the stack. This is
// the React-endorsed "adjust state during render" pattern (guarded to run
// Initialize/Append deck when data arrives // exactly once), not an effect.
if (articles && (!deck || deck.length === 0)) { const [deck, setDeck] = useState<Article[]>([]);
const [seeded, setSeeded] = useState(false);
if (!seeded && articles && articles.length > 0) {
setDeck(articles); 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 invalidate = () => {
const markReadMutation = useMutation({ queryClient.invalidateQueries({ queryKey: ['articles'] });
mutationFn: (id: string) => articlesApi.markRead(id), queryClient.invalidateQueries({ queryKey: ['feeds'] });
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 handleToggleFavorite = (id: string) => { const markReadMutation = useMutation({ mutationFn: (id: string) => articlesApi.markRead(id), onSuccess: invalidate });
toggleFavoriteMutation.mutate(id); const toggleFavoriteMutation = useMutation({ mutationFn: (id: string) => articlesApi.toggleFavorite(id), onSuccess: invalidate });
};
const handleKeep = (id: string) => { if (isLoading && deck.length === 0) {
// Do nothing api-wise, just skip.
console.log('Skipped:', id);
};
if (isLoading && (!deck || deck.length === 0)) {
return ( return (
<div className="flex items-center justify-center h-full bg-carbon/50 backdrop-blur-xl"> <div className="flex items-center justify-center h-full bg-carbon/60 backdrop-blur-xl">
<div className="w-16 h-16 border-4 border-nature border-t-transparent rounded-full animate-spin" /> <div className="w-14 h-14 border-2 border-nature/20 border-t-nature rounded-full animate-spin" />
</div> </div>
); );
} }
// Filter out locally read? No, we handle that in stack index. if (deck.length === 0 || isComplete) {
// If deck is empty return (
if (!deck || deck.length === 0) { <div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-2xl flex items-center justify-center">
return <FocusEmptyState onBack={onExit} />; <FocusEmptyState onBack={onExit} />
} </div>
);
if (isComplete) {
return <FocusEmptyState onBack={onExit} />;
} }
return ( return (
<div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-3xl flex flex-col"> <div className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-2xl flex flex-col">
{/* Header */}
<header className="absolute top-0 left-0 right-0 p-6 flex justify-between items-center z-50"> <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"> <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" /> <span className="w-2 h-2 rounded-full bg-earth animate-pulse" />
<span className="text-xs font-bold uppercase tracking-widest text-paper-white">Mode Focus</span> <span className="eyebrow text-paper-white">Mode Focus</span>
</div> </div>
<button <button onClick={onExit} className="btn-secondary" title="Quitter le mode Focus">
onClick={onExit} <span className="hidden sm:inline">Tableau de bord</span>
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" <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</header> </header>
{/* Main Deck Area */}
<main className="flex-1 flex items-center justify-center p-4"> <main className="flex-1 flex items-center justify-center p-4">
<FocusCardStack <FocusCardStack
articles={deck} articles={deck}
onMarkRead={handleMarkRead} onMarkRead={(id) => markReadMutation.mutate(id)}
onKeep={handleKeep} onKeep={() => { /* skip — no API action */ }}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
onEmpty={() => setIsComplete(true)} onEmpty={() => setIsComplete(true)}
/> />
</main> </main>
+62 -67
View File
@@ -1,17 +1,22 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { ApiError } from '../api/client'; import { ApiError } from '../api/client';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { AuthShell } from '../components/AuthShell';
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const flashMessage = (location.state as { message?: string } | null)?.message;
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationFn: authApi.login, mutationFn: authApi.login,
onSuccess: (data) => { onSuccess: (data) => {
@@ -19,11 +24,7 @@ export function LoginPage() {
navigate('/'); navigate('/');
}, },
onError: (err: Error) => { onError: (err: Error) => {
if (err instanceof ApiError) { setError(err instanceof ApiError ? err.message : 'Échec de la connexion. Veuillez réessayer.');
setError(err.message);
} else {
setError('Échec de la connexion. Veuillez réessayer.');
}
}, },
}); });
@@ -34,70 +35,64 @@ export function LoginPage() {
}; };
return ( return (
<div className="min-h-screen bg-carbon flex items-center justify-center p-6"> <AuthShell eyebrow="Magazine personnel" title="Bon retour" subtitle="Reprenez votre veille là où vous l'avez laissée.">
<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"> <form onSubmit={handleSubmit} className="space-y-5" noValidate>
<div className="text-center mb-10"> {flashMessage && !error && (
<h1 className="text-gold text-4xl font-serif mb-2 italic tracking-tight">FlowReader</h1> <p className="text-nature text-xs bg-nature/10 border border-nature/20 rounded-xl p-3" role="status">
<p className="text-paper-muted text-[10px] uppercase tracking-[0.3em] font-bold">Magazine Personnel</p> {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> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <div className="space-y-2">
{error && ( <label htmlFor="password" className="block eyebrow text-paper-muted">Mot de passe</label>
<div className="bg-red-500/10 border border-red-500/50 text-red-500 text-xs p-3 rounded-md animate-shake"> <input
{error} id="password"
</div> type="password"
)} autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-field"
placeholder="••••••••"
required
/>
</div>
<div className="space-y-2"> <motion.button
<label htmlFor="email" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold"> type="submit"
Email disabled={loginMutation.isPending}
</label> whileTap={{ scale: 0.98 }}
<input className="btn-primary w-full py-4 text-xs"
id="email" >
type="email" {loginMutation.isPending ? 'Authentification…' : 'Continuer'}
value={email} </motion.button>
onChange={(e) => setEmail(e.target.value)} </form>
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>
<div className="space-y-2"> <p className="mt-8 text-center text-paper-muted text-sm">
<label htmlFor="password" title="password" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold"> Nouveau chez FlowReader ?{' '}
Mot de passe <button onClick={() => navigate('/register')} className="text-nature font-bold hover:underline underline-offset-4">
</label> Créer un compte
<input </button>
id="password" </p>
type="password" </AuthShell>
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>
); );
} }
+69 -84
View File
@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { ApiError } from '../api/client'; import { ApiError } from '../api/client';
import { AuthShell } from '../components/AuthShell';
export function RegisterPage() { export function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,107 +19,90 @@ export function RegisterPage() {
navigate('/login', { state: { message: 'Compte créé ! Vous pouvez vous connecter.' } }); navigate('/login', { state: { message: 'Compte créé ! Vous pouvez vous connecter.' } });
}, },
onError: (err: Error) => { onError: (err: Error) => {
if (err instanceof ApiError) { setError(err instanceof ApiError ? err.message : "L'inscription a échoué. Veuillez réessayer.");
setError(err.message);
} else {
setError('L\'inscription a échoué. Veuillez réessayer.');
}
}, },
}); });
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas'); setError('Les mots de passe ne correspondent pas.');
return; return;
} }
registerMutation.mutate({ email, password }); registerMutation.mutate({ email, password });
}; };
return ( return (
<div className="min-h-screen bg-carbon flex items-center justify-center p-6"> <AuthShell eyebrow="Rejoindre l'édition" title="Créez votre journal" subtitle="Une veille calme, intelligente et sans bruit.">
<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"> <form onSubmit={handleSubmit} className="space-y-5" noValidate>
<div className="text-center mb-10"> {error && (
<h1 className="text-gold text-4xl font-serif mb-2 italic tracking-tight">FlowReader</h1> <p className="text-danger text-xs bg-danger/10 border border-danger/30 rounded-xl p-3 animate-shake" role="alert">
<p className="text-paper-muted text-[10px] uppercase tracking-[0.3em] font-bold">Rejoindre l'Édition</p> {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> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <div className="space-y-2">
{error && ( <label htmlFor="password" className="block eyebrow text-paper-muted">Mot de passe</label>
<div className="bg-red-500/10 border border-red-500/50 text-red-500 text-xs p-3 rounded-md animate-shake"> <input
{error} id="password"
</div> 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"> <div className="space-y-2">
<label htmlFor="email" className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold"> <label htmlFor="confirmPassword" className="block eyebrow text-paper-muted">Confirmer le mot de passe</label>
Email <input
</label> id="confirmPassword"
<input type="password"
id="email" autoComplete="new-password"
type="email" value={confirmPassword}
value={email} onChange={(e) => setConfirmPassword(e.target.value)}
onChange={(e) => setEmail(e.target.value)} className="input-field"
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="••••••••"
placeholder="vous@exemple.com" required
required minLength={8}
/> />
</div> </div>
<div className="space-y-2"> <motion.button
<label htmlFor="password" title='password' className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold"> type="submit"
Mot de passe disabled={registerMutation.isPending}
</label> whileTap={{ scale: 0.98 }}
<input className="btn-primary w-full py-4 text-xs"
id="password" >
type="password" {registerMutation.isPending ? 'Création…' : 'Créer mon compte'}
value={password} </motion.button>
onChange={(e) => setPassword(e.target.value)} </form>
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>
<div className="space-y-2"> <p className="mt-8 text-center text-paper-muted text-sm">
<label htmlFor="confirmPassword" title='confirm password' className="block text-paper-muted text-[10px] uppercase tracking-widest font-bold"> Déjà membre ?{' '}
Confirmer le mot de passe <button onClick={() => navigate('/login')} className="text-nature font-bold hover:underline underline-offset-4">
</label> Se connecter
<input </button>
id="confirmPassword" </p>
type="password" </AuthShell>
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>
); );
} }