feat(ui): premium refinement UX Luxe + Add Feed functionality

This commit is contained in:
Michael committed 2026-02-04 16:36:21 +01:00
1 parent 502b76c5d6
commit 1263a12ca2
7 files changed
+343 -272

No files matched your search

+5 -7
View File
@@ -5,28 +5,26 @@ import { useAuthStore } from './stores/authStore';
import { authApi } from './api/auth';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { DashboardPage } from './pages/DashboardPage';
import { RootLayout } from './layouts/RootLayout';
import { ProtectedRoute } from './components/ProtectedRoute';
import './App.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
staleTime: 5 * 60 * 1000,
},
},
});
function AuthChecker({ children }: { children: React.ReactNode }) {
const { setUser, setLoading } = useAuthStore();
const setUser = useAuthStore((state) => state.setUser);
useEffect(() => {
// Check if user is authenticated on app load
authApi.getMe()
.then((user) => setUser(user))
.catch(() => setUser(null));
}, [setUser, setLoading]);
}, [setUser]);
return <>{children}</>;
}
@@ -40,7 +38,7 @@ function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/" element={<RootLayout />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedsApi } from '../api/feeds';
interface AddFeedModalProps {
isOpen: boolean;
onClose: () => void;
}
export function AddFeedModal({ isOpen, onClose }: AddFeedModalProps) {
const [url, setUrl] = useState('');
const queryClient = useQueryClient();
const addFeedMutation = useMutation({
mutationFn: (url: string) => feedsApi.add({ url }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feeds'] });
setUrl('');
onClose();
},
});
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addFeedMutation.mutate(url);
};
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 glass-panel p-8 rounded-2xl shadow-2xl border border-gold/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-gold 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>
<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>
<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>
{addFeedMutation.isError && (
<p className="text-red-400 text-xs italic">É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>
);
}
+52 -48
View File
@@ -8,63 +8,67 @@ interface ArticleCardProps {
}
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
// Simulate a "reading time" based on typical RSS snippet length
const readTime = Math.max(1, Math.ceil((article.content?.length || 500) / 1000));
const timeToRead = Math.ceil((article.content?.length || 500) / 1000) + 1;
return (
<article
className={`magazine-card group cursor-pointer flex flex-col h-full bg-carbon-light transition-all duration-500 overflow-hidden relative shadow-lg hover:gold-glow ${!article.is_read ? 'border-gold/30' : 'border-transparent opacity-90'
}`}
onClick={() => onClick(article)}
>
<div className="aspect-[16/10] overflow-hidden bg-carbon-dark relative">
{/* Fallback pattern for articles without images */}
<div className="absolute inset-0 bg-gradient-to-br from-carbon-light to-carbon-dark opacity-40 group-hover:opacity-20 transition-opacity" />
<div className="absolute inset-0 flex items-center justify-center opacity-10 group-hover:scale-110 transition-transform duration-700">
<span className="text-4xl font-serif text-gold underline italic">F</span>
</div>
<article className="magazine-card group cursor-pointer" onClick={() => onClick(article)}>
{/* Image Placeholder with Gold Overlay */}
<div className="aspect-[16/10] overflow-hidden rounded-xl bg-carbon-light relative mb-6">
<div className="absolute inset-0 bg-gradient-to-t from-carbon-dark/80 via-transparent to-transparent opacity-60 z-10"></div>
{!article.is_read && (
<div className="absolute top-4 right-4 w-2 h-2 bg-gold rounded-full gold-glow animate-pulse z-10" />
)}
{/* Visual placeholder */}
<div className="w-full h-full bg-[url('https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800')] bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-40"></div>
{/* Meta on top of image */}
<div className="absolute bottom-4 left-4 right-4 z-20 flex justify-between items-end">
<span className="text-[10px] text-gold-bright uppercase tracking-[.25em] font-black drop-shadow-md">
{article.feed_title || 'Journal'}
</span>
<span className="text-[10px] text-paper-muted uppercase tracking-widest font-bold">
{timeToRead} min
</span>
</div>
</div>
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] uppercase tracking-[0.2em] text-gold font-bold truncate max-w-[70%]">
{article.feed_title || 'Source'}
</span>
<span className="text-[10px] text-paper-muted uppercase tracking-wider font-sans">
{readTime} MIN READ
</span>
</div>
<h2 className="text-xl font-serif text-paper-white group-hover:text-gold transition-colors duration-300 leading-tight mb-4 flex-1">
{/* Content */}
<div className="space-y-4 px-1">
<h2 className={`text-xl font-serif italic transition-colors duration-300 group-hover:text-gold ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-bold'}`}>
{article.title}
</h2>
<div className="flex items-center justify-between pt-4 border-t border-carbon-dark/50">
<button
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(article.id);
}}
className={`text-xl transition-all duration-300 transform hover:scale-125 ${article.is_favorite ? 'text-gold' : 'text-paper-muted hover:text-gold opacity-30 hover:opacity-100'
}`}
>
{article.is_favorite ? '★' : '☆'}
</button>
{article.summary && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-gold/0 group-hover:opacity-100 transition-opacity">
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}...
</p>
)}
<button
onClick={(e) => {
e.stopPropagation();
onToggleRead(article.id, article.is_read);
}}
className={`text-[10px] uppercase tracking-widest font-bold transition-all duration-300 ${article.is_read ? 'text-paper-muted hover:text-paper-white' : 'text-gold hover:brightness-125'
}`}
>
{article.is_read ? 'Lu' : 'Marquer lu'}
</button>
<div className="flex items-center justify-between pt-2">
<div className="flex space-x-4">
<button
onClick={(e) => {
e.stopPropagation();
onToggleRead(article.id, !article.is_read);
}}
className={`p-1.5 rounded-full transition-all duration-300 ${article.is_read ? 'text-gold/40 border border-gold/10' : 'text-gold hover:bg-gold/10 border border-transparent hover:border-gold/20'}`}
title={article.is_read ? "Marquer comme non lu" : "Marquer comme lu"}
>
<svg className="w-4 h-4" fill={article.is_read ? "none" : "currentColor"} viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(article.id);
}}
className={`p-1.5 rounded-full transition-all duration-300 ${article.is_favorite ? 'text-gold gold-glow' : 'text-paper-muted hover:text-gold hover:bg-gold/10'}`}
title="Ajouter aux favoris"
>
<svg className="w-4 h-4" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
</div>
</div>
</div>
</article>
+63 -58
View File
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { feedsApi } from '../api/feeds';
import { AddFeedModal } from './AddFeedModal';
interface SidebarProps {
onSelectFeed: (feedId: string | null) => void;
@@ -7,77 +9,80 @@ interface SidebarProps {
}
export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const { data: feeds } = useQuery({
queryKey: ['feeds'],
queryFn: () => feedsApi.list(),
});
return (
<aside className="w-64 bg-carbon-light border-r border-carbon-dark h-screen flex flex-col transition-all duration-300 shrink-0">
<div className="p-6">
<h1 className="text-gold text-2xl font-serif tracking-tight">FlowReader</h1>
<p className="text-paper-muted text-[10px] mt-1 uppercase tracking-[0.2em] font-sans">Magazine Instantané</p>
</div>
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
<button
onClick={() => onSelectFeed(null)}
className={`w-full text-left px-4 py-3 rounded-md transition-all duration-200 flex items-center space-x-3 group ${selectedFeedId === null
? 'bg-gold/10 text-gold border-l-4 border-gold'
: 'text-paper-white hover:bg-carbon-dark'
}`}
>
<span className="text-lg group-hover:scale-110 transition-transform">📰</span>
<span className="font-medium">Nouveautés</span>
</button>
<button
onClick={() => onSelectFeed('favorites')}
className={`w-full text-left px-4 py-3 rounded-md transition-all duration-200 flex items-center space-x-3 group ${selectedFeedId === 'favorites'
? 'bg-gold/10 text-gold border-l-4 border-gold'
: 'text-paper-white hover:bg-carbon-dark'
}`}
>
<span className="text-lg group-hover:scale-110 transition-transform">⭐</span>
<span className="font-medium">Favoris</span>
</button>
<div className="pt-8 pb-2 px-4">
<span className="text-paper-muted text-[10px] uppercase tracking-[0.3em] font-bold">Mes Flux</span>
</div>
<div className="space-y-1">
{feeds?.map((feed) => (
<>
<aside className="w-72 bg-carbon-dark/50 backdrop-blur-xl border-r border-white/5 h-screen flex flex-col z-20">
<div className="p-8 pb-4">
<div className="flex items-center justify-between mb-8">
<h1 className="text-gold text-2xl font-serif italic tracking-tight">FlowReader</h1>
<button
key={feed.id}
onClick={() => onSelectFeed(feed.id)}
className={`w-full text-left px-4 py-2 rounded-md transition-all duration-200 truncate group flex justify-between items-center ${selectedFeedId === feed.id
? 'bg-gold/5 text-gold'
: 'text-paper-muted hover:text-paper-white hover:bg-carbon-dark'
onClick={() => setIsAddModalOpen(true)}
className="w-8 h-8 flex items-center justify-center rounded-full border border-gold/20 text-gold hover:bg-gold hover:text-carbon transition-all duration-300 shadow-sm"
title="Ajouter un flux"
>
<span className="text-xl leading-none">+</span>
</button>
</div>
<nav className="space-y-1">
<button
onClick={() => onSelectFeed(null)}
className={`w-full group flex items-center justify-between px-4 py-3 rounded-lg transition-all duration-300 ${selectedFeedId === null
? 'bg-gold/10 text-gold-bright shadow-[inset_0_1px_1px_rgba(255,255,255,0.05)]'
: 'text-paper-muted hover:text-white hover:bg-white/5'
}`}
>
<span className="truncate">
<span className="mr-2 opacity-30 group-hover:opacity-100 transition-opacity">#</span>
{feed.title}
</span>
{(feed.unread_count ?? 0) > 0 && (
<span className="bg-gold/20 text-gold text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.2rem] text-center">
{feed.unread_count}
</span>
)}
<span className="text-xs uppercase tracking-[0.2em] font-bold">Tout voir</span>
</button>
))}
</nav>
</div>
</nav>
<div className="p-6 border-t border-carbon-dark bg-carbon-dark/20">
<div className="flex items-center space-x-3 text-paper-muted hover:text-gold cursor-pointer transition-all duration-300 group">
<div className="w-8 h-8 rounded-full bg-carbon border border-carbon-light flex items-center justify-center group-hover:border-gold group-hover: gold-glow transition-all">
<span className="text-sm">⚙️</span>
<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">
{feeds?.map((feed) => (
<button
key={feed.id}
onClick={() => onSelectFeed(feed.id)}
className={`w-full group flex items-center justify-between px-4 py-2.5 rounded-lg transition-all duration-300 text-left ${selectedFeedId === feed.id
? 'bg-gold/10 text-gold-bright'
: 'text-paper-muted hover:text-white hover:bg-white/5'
}`}
>
<span className="text-sm font-medium truncate flex items-center">
<span className="w-1.5 h-1.5 rounded-full bg-gold/40 mr-3 opacity-0 group-hover:opacity-100 transition-opacity"></span>
{feed.title}
</span>
{(feed.unread_count ?? 0) > 0 && (
<span className="bg-gold/20 text-gold text-[10px] px-1.5 py-0.5 rounded-full min-w-[1.2rem] text-center font-bold">
{feed.unread_count}
</span>
)}
</button>
))}
</div>
</div>
<span className="text-sm font-medium">Paramètres</span>
</div>
</div>
</aside>
<div className="p-6 border-t border-white/5">
<div className="p-4 rounded-xl bg-gold/5 border border-gold/10">
<p className="text-[10px] text-gold/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>
</div>
</aside>
<AddFeedModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
/>
</>
);
}
+58 -19
View File
@@ -1,29 +1,42 @@
@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 "tailwindcss";
@theme {
--color-carbon: #1a1a1a;
--color-carbon-light: #262626;
--color-carbon-dark: #121212;
--color-gold: #d4af37;
--color-paper-white: #e5e5e5;
--color-paper-muted: #a3a3a3;
--color-carbon: #121212;
--color-carbon-light: #1e1e1e;
--color-carbon-dark: #0a0a0a;
--color-gold: #c5a059;
--color-gold-bright: #e2c28d;
--color-paper-white: #f5f5f5;
--color-paper-muted: #888888;
--font-serif: "Playfair Display", serif;
--font-sans: Inter, sans-serif;
--font-reading: Lora, serif;
--font-sans: "Inter", system-ui, sans-serif;
--font-reading: "Lora", serif;
--line-height-magazine: 1.7;
--line-height-magazine: 1.8;
}
@utility gold-glow {
box-shadow: 0 0 15px rgba(212, 175, 55, 0.2);
box-shadow: 0 0 20px rgba(197, 160, 89, 0.15);
}
@utility glass-panel {
background: rgba(30, 30, 30, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
@layer base {
body {
@apply bg-carbon text-paper-white font-sans antialiased;
@apply bg-carbon text-paper-white font-sans antialiased selection:bg-gold/30;
margin: 0;
min-height: 100vh;
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)'/%3E%3C/svg%3E");
background-size: 200px 200px;
background-repeat: repeat;
background-blend-mode: overlay;
}
h1,
@@ -32,26 +45,52 @@
h4,
h5,
h6 {
@apply font-serif;
@apply font-serif tracking-tight leading-tight;
}
a {
@apply transition-colors duration-200;
@apply transition-all duration-300 decoration-gold/30 underline-offset-4 hover:decoration-gold;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(197, 160, 89, 0.2);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(197, 160, 89, 0.4);
}
}
@layer components {
.magazine-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 3rem;
padding: 2rem 0;
}
.magazine-card {
@apply bg-carbon-light border border-carbon-light hover:border-gold transition-all duration-300 rounded-lg overflow-hidden;
@apply relative transition-all duration-500 ease-out;
}
.btn-primary {
@apply bg-gold text-carbon font-medium px-4 py-2 rounded-md hover:brightness-110 transition-all;
@apply bg-gold text-carbon font-bold px-6 py-2.5 rounded-full hover:bg-gold-bright hover:-translate-y-0.5 transition-all shadow-lg active:scale-95 text-xs uppercase tracking-widest;
}
.btn-secondary {
@apply border border-gold text-gold px-4 py-2 rounded-md hover:bg-gold/10 transition-all;
.btn-ghost {
@apply text-paper-muted hover:text-gold hover:bg-gold/5 px-4 py-2 rounded-lg transition-all text-sm;
}
}
@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');
.input-field {
@apply bg-carbon-dark border border-white/10 text-paper-white px-4 py-3 rounded-lg focus:border-gold/50 focus:ring-1 focus:ring-gold/50 outline-none transition-all placeholder:text-paper-muted/30;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { useState } from 'react';
import { Sidebar } from '../components/Sidebar';
import { DashboardPage } from '../pages/DashboardPage';
export function RootLayout() {
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
return (
<div className="flex h-screen bg-carbon overflow-hidden">
<Sidebar
onSelectFeed={setSelectedFeedId}
selectedFeedId={selectedFeedId}
/>
<DashboardPage
selectedFeedId={selectedFeedId}
/>
</div>
);
}
+59 -140
View File
@@ -1,172 +1,91 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { authApi } from '../api/auth';
import { articlesApi } from '../api/articles';
import type { Article } from '../api/articles';
import { Sidebar } from '../components/Sidebar';
import { ArticleCard } from '../components/ArticleCard';
import { ReaderView } from '../components/ReaderView';
import { useWebsocket } from '../hooks/useWebsocket';
import type { Article } from '../api/articles';
export function DashboardPage() {
const navigate = useNavigate();
interface DashboardPageProps {
selectedFeedId: string | null;
}
export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
const queryClient = useQueryClient();
const { user, logout } = useAuthStore();
// Initialize WebSocket for real-time updates
useWebsocket();
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
const [viewingArticle, setViewingArticle] = useState<Article | null>(null);
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
const { data: articles, isLoading } = useQuery({
queryKey: ['articles', selectedFeedId],
queryFn: () => articlesApi.list({
unread: true,
feed_id: (selectedFeedId && selectedFeedId !== 'favorites') ? selectedFeedId : undefined,
favorite: selectedFeedId === 'favorites' ? true : undefined
}),
queryFn: () => articlesApi.list({ feed_id: selectedFeedId || undefined }),
});
const toggleReadMutation = useMutation({
mutationFn: ({ id, is_read }: { id: string, is_read: boolean }) =>
is_read ? articlesApi.markUnread(id) : articlesApi.markRead(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['feeds'] });
},
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const toggleFavoriteMutation = useMutation({
mutationFn: ({ id }: { id: string }) => articlesApi.toggleFavorite(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
mutationFn: articlesApi.toggleFavorite,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const markAllReadMutation = useMutation({
mutationFn: () => selectedFeedId && selectedFeedId !== 'favorites'
? articlesApi.markAllRead(selectedFeedId)
: articlesApi.markAllReadGlobal(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
queryClient.invalidateQueries({ queryKey: ['feeds'] });
},
});
const handleLogout = async () => {
await authApi.logout();
logout();
navigate('/login');
};
const handleArticleClick = async (article: Article) => {
try {
const fullArticle = await articlesApi.get(article.id);
setViewingArticle(fullArticle);
if (!article.is_read) {
toggleReadMutation.mutate({ id: article.id, is_read: false });
}
} catch (err) {
console.error('Failed to load article', err);
}
};
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-gold rounded-full animate-spin"></div>
<p className="text-paper-muted text-[10px] uppercase tracking-[0.4em] font-black">Composition de votre édition...</p>
</div>
);
}
return (
<div className="flex h-screen bg-carbon overflow-hidden">
<Sidebar
onSelectFeed={setSelectedFeedId}
selectedFeedId={selectedFeedId}
/>
<div className="flex-1 flex flex-col min-w-0 bg-carbon overflow-hidden relative">
<header className="h-20 border-b border-carbon-dark/50 flex items-center justify-between px-8 bg-carbon/50 backdrop-blur-md z-10 shrink-0">
<div className="min-w-0">
<h1 className="text-gold text-2xl md:text-3xl font-serif italic truncate">
{selectedFeedId === 'favorites' ? 'Mes Favoris' : 'Nouveautés'}
</h1>
<p className="text-paper-muted text-[10px] uppercase tracking-widest font-bold">
{articles?.length || 0} ARTICLES À DÉCOUVRIR
</p>
</div>
<div className="flex items-center space-x-6 shrink-0">
{articles && articles.length > 0 && selectedFeedId !== 'favorites' && (
<button
className="btn-secondary text-[10px] uppercase tracking-widest font-bold py-2 px-4 border-gold/30 hidden md:block"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
{markAllReadMutation.isPending ? 'Patientez...' : 'Tout marquer lu'}
</button>
)}
<div className="flex items-center space-x-4 border-l border-carbon-dark/50 pl-6">
<div className="text-right hidden sm:block">
<p className="text-paper-white text-xs font-medium truncate max-w-[150px]">{user?.email}</p>
<button onClick={handleLogout} className="text-paper-muted hover:text-gold text-[10px] uppercase tracking-tighter transition-colors">
Déconnexion
</button>
</div>
<div className="w-10 h-10 rounded-full bg-gold/10 border border-gold/20 flex items-center justify-center text-gold font-serif text-lg shrink-0">
{user?.email?.[0].toUpperCase() || 'M'}
</div>
<main className="flex-1 overflow-y-auto bg-carbon scroll-smooth">
<div className="max-w-[1400px] mx-auto px-12 py-16">
{/* Header */}
<header className="mb-20 space-y-4 border-b border-white/5 pb-10">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-gold text-[10px] uppercase tracking-[0.5em] font-black">Édition du jour</p>
<h1 className="text-6xl font-serif italic text-paper-white tracking-tighter">
{selectedFeedId ? 'Archives du Flux' : 'La Une'}
</h1>
</div>
<div className="text-right">
<p className="text-paper-muted text-xs font-serif italic">Mercredi 4 Février 2026</p>
<p className="text-[10px] text-paper-muted/30 uppercase tracking-widest font-black mt-1">Volume IX • No. 42</p>
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto px-4 md:px-8 py-10">
{isLoading ? (
<div className="flex flex-col items-center justify-center h-full space-y-4 opacity-50">
<div className="w-12 h-12 border-2 border-gold/20 border-t-gold rounded-full animate-spin" />
<p className="text-gold text-xs uppercase tracking-widest animate-pulse">Édition en cours...</p>
</div>
) : articles?.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center space-y-6 animate-in fade-in duration-1000">
<div className="text-6xl filter grayscale opacity-30">💆‍♂️</div>
<div className="max-w-md">
<h2 className="text-paper-white text-2xl font-serif mb-2">Immersion Totale</h2>
<p className="text-paper-muted font-reading text-lg">
Vous avez épuisé vos flux. Profitez de ce moment de silence.
</p>
{/* Grid */}
{articles && articles.length > 0 ? (
<div className="magazine-grid">
{articles.map((article, index) => (
<div key={article.id} className={index % 6 === 0 ? 'md:col-span-2' : ''}>
<ArticleCard
article={article}
onClick={setSelectedArticle}
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
/>
</div>
<button
className="btn-secondary text-[10px] uppercase tracking-[0.2em] font-bold"
onClick={() => queryClient.invalidateQueries({ queryKey: ['articles'] })}
>
Actualiser
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-8">
{articles?.map((article: Article, index: number) => (
<div
key={article.id}
className={`${index === 0 ? 'md:col-span-2' : ''} animate-in fade-in slide-in-from-bottom-4 duration-500`}
style={{ animationDelay: `${index * 50}ms` }}
>
<ArticleCard
article={article}
onClick={handleArticleClick}
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })}
/>
</div>
))}
</div>
)}
</main>
))}
</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">Votre bibliothèque est vide.</p>
<p className="text-paper-muted/40 text-[10px] uppercase tracking-widest font-bold">Ajoutez un flux pour commencer votre lecture.</p>
</div>
)}
</div>
{viewingArticle && (
{selectedArticle && (
<ReaderView
article={viewingArticle}
onClose={() => setViewingArticle(null)}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })}
article={selectedArticle}
onClose={() => setSelectedArticle(null)}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
/>
)}
</div>
</main>
);
}