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