mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-17 12:17:31 +02:00
feat(web): overhaul frontend Paper Dark, migration Tailwind v4 et composants magazine
This commit is contained in:
1 parent
8777d3b970
commit
8ed1782de6
16 files changed
+1998
-238
No files matched your search
@@ -3,54 +3,70 @@ import type { Article } from '../api/articles';
|
||||
interface ArticleCardProps {
|
||||
article: Article;
|
||||
onClick: (article: Article) => void;
|
||||
onToggleRead: (id: string, currentStatus: boolean) => void;
|
||||
onToggleFavorite: (id: string, currentStatus: boolean) => void;
|
||||
onToggleRead: (id: string, is_read: boolean) => void;
|
||||
onToggleFavorite: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||
const publishedDate = article.published_at
|
||||
? new Date(article.published_at).toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: '';
|
||||
// Simulate a "reading time" based on typical RSS snippet length
|
||||
const readTime = Math.max(1, Math.ceil((article.content?.length || 500) / 1000));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`article-card ${article.is_read ? 'read' : 'unread'}`}
|
||||
<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="article-header">
|
||||
<span className="feed-title">{article.feed_title}</span>
|
||||
<span className="published-date">{publishedDate}</span>
|
||||
<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.is_read && (
|
||||
<div className="absolute top-4 right-4 w-2 h-2 bg-gold rounded-full gold-glow animate-pulse z-10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="article-title">{article.title}</h3>
|
||||
<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>
|
||||
|
||||
{article.summary && (
|
||||
<p className="article-excerpt">
|
||||
{article.summary.replace(/<[^>]*>/g, '').substring(0, 150)}...
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-xl font-serif text-paper-white group-hover:text-gold transition-colors duration-300 leading-tight mb-4 flex-1">
|
||||
{article.title}
|
||||
</h2>
|
||||
|
||||
<div className="article-footer" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className={`action-btn ${article.is_favorite ? 'active' : ''}`}
|
||||
onClick={() => onToggleFavorite(article.id, article.is_favorite)}
|
||||
title={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
|
||||
>
|
||||
{article.is_favorite ? '★' : '☆'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => onToggleRead(article.id, article.is_read)}
|
||||
title={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
|
||||
>
|
||||
{article.is_read ? '○' : '●'}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -3,52 +3,86 @@ import type { Article } from '../api/articles';
|
||||
interface ReaderViewProps {
|
||||
article: Article;
|
||||
onClose: () => void;
|
||||
onToggleFavorite: (id: string, currentStatus: boolean) => void;
|
||||
onToggleFavorite: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ReaderView({ article, onClose, onToggleFavorite }: ReaderViewProps) {
|
||||
const publishedDate = article.published_at
|
||||
? new Date(article.published_at).toLocaleString()
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="reader-overlay" onClick={onClose}>
|
||||
<div className="reader-container" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="reader-header">
|
||||
<button className="close-btn" onClick={onClose}>✕ Fermer</button>
|
||||
<div className="reader-actions">
|
||||
<button
|
||||
className={`action-btn ${article.is_favorite ? 'active' : ''}`}
|
||||
onClick={() => onToggleFavorite(article.id, article.is_favorite)}
|
||||
>
|
||||
{article.is_favorite ? '★ Favori' : '☆ Favori'}
|
||||
</button>
|
||||
<a
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="action-btn"
|
||||
>
|
||||
🌐 Source
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-carbon/95 backdrop-blur-sm flex justify-center overflow-y-auto animate-in fade-in duration-500"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-reading bg-carbon-light min-h-screen my-0 md:my-12 shadow-2xl relative animate-in slide-in-from-bottom duration-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Progress bar simulation */}
|
||||
<div className="sticky top-0 left-0 right-0 h-1 bg-gold/10 z-20">
|
||||
<div className="h-full bg-gold w-1/3 gold-glow shadow-gold" />
|
||||
</div>
|
||||
|
||||
<article className="reader-content">
|
||||
<div className="meta">
|
||||
<span className="feed-name">{article.feed_title}</span>
|
||||
<span className="date">{publishedDate}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-8 -right-16 hidden md:flex w-10 h-10 items-center justify-center rounded-full border border-gold/30 text-gold hover:bg-gold hover:text-carbon transition-all duration-300"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h1 className="title">{article.title}</h1>
|
||||
<div className="p-8 md:p-16">
|
||||
<header className="mb-12">
|
||||
<div className="flex items-center space-x-3 text-gold text-xs uppercase tracking-[0.3em] font-bold mb-6">
|
||||
<span>{article.feed_title}</span>
|
||||
<span className="opacity-30">•</span>
|
||||
<span>{article.published_at ? new Date(article.published_at).toLocaleDateString() : 'Aujourd\'hui'}</span>
|
||||
</div>
|
||||
|
||||
{article.author && <p className="author">Par {article.author}</p>}
|
||||
<h1 className="text-4xl md:text-6xl font-serif text-paper-white leading-[1.1] mb-8 italic">
|
||||
{article.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center justify-between py-6 border-y border-carbon-dark/50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => onToggleFavorite(article.id)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-full border transition-all ${article.is_favorite
|
||||
? 'bg-gold text-carbon border-gold'
|
||||
: 'border-gold/30 text-gold hover:bg-gold/10'
|
||||
}`}
|
||||
>
|
||||
<span>{article.is_favorite ? '★' : '☆'}</span>
|
||||
<span className="text-xs uppercase tracking-widest font-bold">
|
||||
{article.is_favorite ? 'Favori' : 'Ajouter'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button className="p-2 text-paper-muted hover:text-gold transition-colors">
|
||||
📤
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs uppercase tracking-widest font-bold text-gold hover:underline"
|
||||
>
|
||||
Lire sur le site original
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="content-body"
|
||||
dangerouslySetInnerHTML={{ __html: article.content || article.summary || '' }}
|
||||
className="prose prose-invert prose-gold max-w-none text-paper-white/90 text-xl font-reading leading-magazine space-y-8"
|
||||
dangerouslySetInnerHTML={{ __html: article.content || '<p>Aucun contenu disponible pour cet article.</p>' }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<footer className="mt-20 pt-12 border-t border-carbon-dark/50 text-center pb-20">
|
||||
<div className="text-gold text-3xl font-serif mb-4 italic italic-gold">F</div>
|
||||
<p className="text-paper-muted text-sm font-sans tracking-wide">
|
||||
FlowReader Magazine — Immersion Totale.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { feedsApi } from '../api/feeds';
|
||||
import type { Feed } from '../api/feeds';
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectFeed: (feedId: string | null) => void;
|
||||
@@ -8,58 +7,76 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
|
||||
const { data: feeds, isLoading } = useQuery({
|
||||
const { data: feeds } = useQuery({
|
||||
queryKey: ['feeds'],
|
||||
queryFn: feedsApi.list,
|
||||
queryFn: () => feedsApi.list(),
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<nav className="sidebar-nav">
|
||||
<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
|
||||
className={`nav-item ${selectedFeedId === null ? 'active' : ''}`}
|
||||
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="icon">📥</span>
|
||||
<span className="label">Tous les flux</span>
|
||||
<span className="text-lg group-hover:scale-110 transition-transform">📰</span>
|
||||
<span className="font-medium">Nouveautés</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`nav-item ${selectedFeedId === 'favorites' ? 'active' : ''}`}
|
||||
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="icon">★</span>
|
||||
<span className="label">Favoris</span>
|
||||
<span className="text-lg group-hover:scale-110 transition-transform">⭐</span>
|
||||
<span className="font-medium">Favoris</span>
|
||||
</button>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<h3>Flux</h3>
|
||||
{isLoading ? (
|
||||
<div className="sidebar-loading">Chargement...</div>
|
||||
) : (
|
||||
<div className="feed-list">
|
||||
{feeds?.map((feed: Feed) => (
|
||||
<button
|
||||
key={feed.id}
|
||||
className={`nav-item ${selectedFeedId === feed.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectFeed(feed.id)}
|
||||
>
|
||||
<span className="icon">📄</span>
|
||||
<span className="label">{feed.title}</span>
|
||||
{feed.unread_count ? (
|
||||
<span className="badge">{feed.unread_count}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<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) => (
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button className="add-feed-btn" onClick={() => {/* TODO: Add feed modal */ }}>
|
||||
+ Ajouter un flux
|
||||
</button>
|
||||
<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>
|
||||
<span className="text-sm font-medium">Paramètres</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
+47
-58
@@ -1,68 +1,57 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@import "tailwindcss";
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
@theme {
|
||||
--color-carbon: #1a1a1a;
|
||||
--color-carbon-light: #262626;
|
||||
--color-carbon-dark: #121212;
|
||||
--color-gold: #d4af37;
|
||||
--color-paper-white: #e5e5e5;
|
||||
--color-paper-muted: #a3a3a3;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--font-serif: "Playfair Display", serif;
|
||||
--font-sans: Inter, sans-serif;
|
||||
--font-reading: Lora, serif;
|
||||
|
||||
--line-height-magazine: 1.7;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
@utility gold-glow {
|
||||
box-shadow: 0 0 15px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-carbon text-paper-white font-sans antialiased;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-serif;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
|
||||
a {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.magazine-card {
|
||||
@apply bg-carbon-light border border-carbon-light hover:border-gold transition-all duration-300 rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-gold text-carbon font-medium px-4 py-2 rounded-md hover:brightness-110 transition-all;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border border-gold text-gold px-4 py-2 rounded-md hover:bg-gold/10 transition-all;
|
||||
}
|
||||
}
|
||||
|
||||
@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');
|
||||
@@ -64,11 +64,8 @@ export function DashboardPage() {
|
||||
|
||||
const handleArticleClick = async (article: Article) => {
|
||||
try {
|
||||
// Fetch full content and sanitize
|
||||
const fullArticle = await articlesApi.get(article.id);
|
||||
setViewingArticle(fullArticle);
|
||||
|
||||
// Mark as read automatically
|
||||
if (!article.is_read) {
|
||||
toggleReadMutation.mutate({ id: article.id, is_read: false });
|
||||
}
|
||||
@@ -78,56 +75,85 @@ export function DashboardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<div className="flex h-screen bg-carbon overflow-hidden">
|
||||
<Sidebar
|
||||
onSelectFeed={setSelectedFeedId}
|
||||
selectedFeedId={selectedFeedId}
|
||||
/>
|
||||
|
||||
<div className="dashboard-content">
|
||||
<header className="dashboard-header">
|
||||
<h1>{selectedFeedId === 'favorites' ? 'Favoris' : 'Nouveautés'}</h1>
|
||||
<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="header-actions">
|
||||
<div className="flex items-center space-x-6 shrink-0">
|
||||
{articles && articles.length > 0 && selectedFeedId !== 'favorites' && (
|
||||
<button
|
||||
className="mark-all-read-btn"
|
||||
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 ? 'Chargement...' : 'Tout marquer comme lu'}
|
||||
{markAllReadMutation.isPending ? 'Patientez...' : 'Tout marquer lu'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="user-info">
|
||||
<span>{user?.email}</span>
|
||||
<button onClick={handleLogout} className="logout-btn">
|
||||
Déconnexion
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="articles-main">
|
||||
<main className="flex-1 overflow-y-auto px-4 md:px-8 py-10">
|
||||
{isLoading ? (
|
||||
<div className="loading-state">
|
||||
<div className="spinner"></div>
|
||||
<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="empty-state">
|
||||
<h2>🎉 Tout est lu !</h2>
|
||||
<p>Revenez plus tard pour de nouveaux articles.</p>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
className="btn-secondary text-[10px] uppercase tracking-[0.2em] font-bold"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['articles'] })}
|
||||
>
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="articles-grid">
|
||||
{articles?.map((article: Article) => (
|
||||
<ArticleCard
|
||||
<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}
|
||||
article={article}
|
||||
onClick={handleArticleClick}
|
||||
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
|
||||
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ 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>
|
||||
)}
|
||||
|
||||
+40
-21
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { authApi, ApiError } from '../api/auth';
|
||||
import { authApi } from '../api/auth';
|
||||
import { ApiError } from '../api/client';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -21,7 +22,7 @@ export function LoginPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Login failed. Please try again.');
|
||||
setError('Échec de la connexion. Veuillez réessayer.');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -33,50 +34,68 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h1>FlowReader</h1>
|
||||
<p className="subtitle">Connectez-vous à votre compte</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && <div className="error-message">{error}</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="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<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)}
|
||||
placeholder="vous@example.com"
|
||||
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
|
||||
placeholder="vous@exemple.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Mot de passe</label>
|
||||
<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>
|
||||
|
||||
<button type="submit" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? 'Connexion...' : 'Se connecter'}
|
||||
<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="register-link">
|
||||
Pas encore de compte ?{' '}
|
||||
<a href="/register" onClick={(e) => { e.preventDefault(); navigate('/register'); }}>
|
||||
S'inscrire
|
||||
</a>
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { authApi, ApiError } from '../api/auth';
|
||||
import { authApi } from '../api/auth';
|
||||
import { ApiError } from '../api/client';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -19,7 +20,7 @@ export function RegisterPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Registration failed. Please try again.');
|
||||
setError('L\'inscription a échoué. Veuillez réessayer.');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -37,63 +38,84 @@ export function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h1>FlowReader</h1>
|
||||
<p className="subtitle">Créer un nouveau compte</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && <div className="error-message">{error}</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="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<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)}
|
||||
placeholder="vous@example.com"
|
||||
className="w-full bg-carbon border border-carbon-dark text-paper-white px-4 py-3 rounded-md focus:border-gold focus:ring-1 focus:ring-gold outline-none transition-all placeholder:text-carbon-light"
|
||||
placeholder="vous@exemple.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Mot de passe</label>
|
||||
<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>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirmer le mot de passe</label>
|
||||
<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}>
|
||||
<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="register-link">
|
||||
Déjà un compte ?{' '}
|
||||
<a href="/login" onClick={(e) => { e.preventDefault(); navigate('/login'); }}>
|
||||
<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
|
||||
</a>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user