mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(ui): premium refinement UX Luxe + Add Feed functionality
This commit is contained in:
1 parent
502b76c5d6
commit
1263a12ca2
7 files changed
+343
-272
No files matched your search
+5
-7
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user