diff --git a/web/src/App.tsx b/web/src/App.tsx index 90fc47a..28cc786 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> }> - } /> + } /> } /> diff --git a/web/src/components/AddFeedModal.tsx b/web/src/components/AddFeedModal.tsx new file mode 100644 index 0000000..618e88b --- /dev/null +++ b/web/src/components/AddFeedModal.tsx @@ -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 ( +
+
e.stopPropagation()} + > +
+

Nouveau Flux

+ +
+ +

+ Entrez l'URL d'un flux RSS ou Atom. FlowReader récupérera automatiquement le titre et le contenu. +

+ +
+
+ + setUrl(e.target.value)} + className="input-field w-full" + placeholder="https://exemple.com/feed" + required + autoFocus + /> +
+ + {addFeedMutation.isError && ( +

Échec de l'ajout. Vérifiez l'URL du flux.

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/web/src/components/ArticleCard.tsx b/web/src/components/ArticleCard.tsx index 59721b2..cda29e0 100644 --- a/web/src/components/ArticleCard.tsx +++ b/web/src/components/ArticleCard.tsx @@ -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 ( -
onClick(article)} - > -
- {/* Fallback pattern for articles without images */} -
-
- F -
+
onClick(article)}> + {/* Image Placeholder with Gold Overlay */} +
+
- {!article.is_read && ( -
- )} + {/* Visual placeholder */} +
+ + {/* Meta on top of image */} +
+ + {article.feed_title || 'Journal'} + + + {timeToRead} min + +
-
-
- - {article.feed_title || 'Source'} - - - {readTime} MIN READ - -
- -

+ {/* Content */} +
+

{article.title}

-
- + {article.summary && ( +

+ {article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}... +

+ )} - +
+
+ + +

diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 86b70a1..ac9caba 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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 ( - + +
+
+

Mode Zen

+

"Le silence est le bruit des esprits."

+
+
+ + + setIsAddModalOpen(false)} + /> + ); } diff --git a/web/src/index.css b/web/src/index.css index 146711a..ca31e85 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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'); \ No newline at end of file + .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; + } +} \ No newline at end of file diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx new file mode 100644 index 0000000..93eedf9 --- /dev/null +++ b/web/src/layouts/RootLayout.tsx @@ -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(null); + + return ( +
+ + +
+ ); +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 0f325c0..67a03e4 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -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(null); - const [viewingArticle, setViewingArticle] = useState
(null); + const [selectedArticle, setSelectedArticle] = useState
(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 ( +
+
+

Composition de votre édition...

+
+ ); + } return ( -
- - -
-
-
-

- {selectedFeedId === 'favorites' ? 'Mes Favoris' : 'Nouveautés'} -

-

- {articles?.length || 0} ARTICLES À DÉCOUVRIR -

-
- -
- {articles && articles.length > 0 && selectedFeedId !== 'favorites' && ( - - )} - -
-
-

{user?.email}

- -
-
- {user?.email?.[0].toUpperCase() || 'M'} -
+
+
+ {/* Header */} +
+
+
+

Édition du jour

+

+ {selectedFeedId ? 'Archives du Flux' : 'La Une'} +

+
+
+

Mercredi 4 Février 2026

+

Volume IX • No. 42

-
- {isLoading ? ( -
-
-

Édition en cours...

-
- ) : articles?.length === 0 ? ( -
-
💆‍♂️
-
-

Immersion Totale

-

- Vous avez épuisé vos flux. Profitez de ce moment de silence. -

+ {/* Grid */} + {articles && articles.length > 0 ? ( +
+ {articles.map((article, index) => ( +
+ toggleReadMutation.mutate({ id, is_read })} + onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} + />
- -
- ) : ( -
- {articles?.map((article: Article, index: number) => ( -
- toggleReadMutation.mutate({ id, is_read })} - onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })} - /> -
- ))} -
- )} -
+ ))} +
+ ) : ( +
+

Votre bibliothèque est vide.

+

Ajoutez un flux pour commencer votre lecture.

+
+ )}
- {viewingArticle && ( + {selectedArticle && ( setViewingArticle(null)} - onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })} + article={selectedArticle} + onClose={() => setSelectedArticle(null)} + onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)} /> )} -
+ ); }