feat: implement Deep Forest dark mode, swipe gestures, PWA support, and PostgreSQL Full-Text Search

This commit is contained in:
Michael committed 2026-02-09 09:18:40 +01:00
1 parent 3e468d8885
commit 7e802df2a7
18 files changed
+4800 -128

No files matched your search

+1
View File
@@ -122,6 +122,7 @@ func main() {
// Article routes
r.Route("/articles", func(r chi.Router) {
r.Get("/", articleHandler.List)
r.Get("/search", articleHandler.Search)
r.Post("/read-all", articleHandler.MarkAllReadGlobal)
r.Get("/favorites", articleHandler.GetFavorites)
r.Get("/{id}", articleHandler.Get)
+1
View File
@@ -42,4 +42,5 @@ type ArticleRepository interface {
ToggleFavorite(id uuid.UUID) error
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
CountUnread(feedID uuid.UUID) (int, error)
Search(userID uuid.UUID, query string, limit, offset int) ([]*Article, error)
}
+33
View File
@@ -355,3 +355,36 @@ func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, articles)
}
// Search handles GET /api/v1/articles/search
func (h *ArticleHandler) Search(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
query := r.URL.Query().Get("q")
if query == "" {
respondJSON(w, http.StatusOK, []*domain.Article{})
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
articles, err := h.articleRepo.Search(userID, query, limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to search articles")
return
}
respondJSON(w, http.StatusOK, articles)
}
+85
View File
@@ -411,6 +411,91 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro
return articles, nil
}
// Search performs a full-text search on articles for a specific user.
func (r *ArticleRepository) Search(userID uuid.UUID, query string, limit, offset int) ([]*domain.Article, error) {
ctx := context.Background()
// Use plainto_tsquery or websearch_to_tsquery for natural language search
sql := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
a.image_url, a.published_at, a.is_read, a.is_favorite, a.read_at, a.created_at,
f.title as feed_title,
ts_rank_cd(a.tsv, websearch_to_tsquery('french', $2)) as rank
FROM articles a
JOIN feeds f ON f.id = a.feed_id
WHERE f.user_id = $1 AND a.tsv @@ websearch_to_tsquery('french', $2)
ORDER BY rank DESC, a.published_at DESC
LIMIT $3 OFFSET $4
`
rows, err := r.pool.Query(ctx, sql, userID, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("searching articles: %w", err)
}
defer rows.Close()
return r.scanArticlesWithRank(rows)
}
// scanArticlesWithRank scans multiple article rows with their rank.
func (r *ArticleRepository) scanArticlesWithRank(rows pgx.Rows) ([]*domain.Article, error) {
var articles []*domain.Article
for rows.Next() {
var article domain.Article
var url, content, summary, author, imageURL, feedTitle *string
var publishedAt, readAt *time.Time
var rank float32
err := rows.Scan(
&article.ID,
&article.FeedID,
&article.GUID,
&article.Title,
&url,
&content,
&summary,
&author,
&imageURL,
&publishedAt,
&article.IsRead,
&article.IsFavorite,
&readAt,
&article.CreatedAt,
&feedTitle,
&rank,
)
if err != nil {
return nil, fmt.Errorf("scanning article with rank: %w", err)
}
if url != nil {
article.URL = *url
}
if content != nil {
article.Content = *content
}
if summary != nil {
article.Summary = *summary
}
if author != nil {
article.Author = *author
}
if imageURL != nil {
article.ImageURL = *imageURL
}
if feedTitle != nil {
article.FeedTitle = *feedTitle
}
article.PublishedAt = publishedAt
article.ReadAt = readAt
articles = append(articles, &article)
}
return articles, nil
}
// nullString returns nil if string is empty.
func nullString(s string) *string {
if s == "" {
+5
View File
@@ -0,0 +1,5 @@
-- Migration: 005_search_articles_down
-- Description: Remove Full-Text Search support
DROP INDEX IF EXISTS idx_articles_tsv;
ALTER TABLE articles DROP COLUMN IF EXISTS tsv;
+15
View File
@@ -0,0 +1,15 @@
-- Migration: 005_search_articles_up
-- Description: Add Full-Text Search support to articles table
-- Add tsvector column (generated)
-- Weighting: Title (A), Summary (B), Content (C)
ALTER TABLE articles
ADD COLUMN IF NOT EXISTS tsv tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('french', coalesce(title, '')), 'A') ||
setweight(to_tsvector('french', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('french', coalesce(content, '')), 'C')
) STORED;
-- Create GIN index for fast searching
CREATE INDEX IF NOT EXISTS idx_articles_tsv ON articles USING GIN(tsv);
+5 -1
View File
@@ -4,8 +4,12 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="theme-color" content="#166534" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<meta name="description"
content="FlowReader - Votre édition quotidienne pour une lecture plaisir et sans distractions." />
<title>FlowReader | La Une</title>
</head>
<body>
+4274 -1
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -34,6 +34,7 @@
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0"
}
}
+12
View File
@@ -106,4 +106,16 @@ export const articlesApi = {
});
return handleResponse(response);
},
async search(query: string, limit: number = 50, offset: number = 0): Promise<Article[]> {
const params = new URLSearchParams();
params.append('q', query);
params.append('limit', limit.toString());
params.append('offset', offset.toString());
const response = await fetch(`${API_BASE}/articles/search?${params.toString()}`, {
credentials: 'include',
});
return handleResponse<Article[]>(response);
},
};
+157 -90
View File
@@ -1,3 +1,5 @@
import { useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import type { Article } from '../api/articles';
interface ArticleCardProps {
@@ -8,108 +10,173 @@ interface ArticleCardProps {
}
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
const timeToRead = Math.ceil((article.content?.length || 500) / 1000) + 1;
const [swipeOffset, setSwipeOffset] = useState(0);
const [isSwiping, setIsSwiping] = useState(false);
const handlers = useSwipeable({
onSwiping: (event) => {
const offset = event.deltaX;
// Limit swipe to 80px
if (Math.abs(offset) < 100) {
setSwipeOffset(offset);
setIsSwiping(true);
}
},
onSwipedRight: () => {
if (swipeOffset > 50) {
onToggleRead(article.id, !article.is_read);
}
resetSwipe();
},
onSwipedLeft: () => {
if (swipeOffset < -50) {
onToggleFavorite(article.id);
}
resetSwipe();
},
onSwiped: () => resetSwipe(),
trackMouse: true,
preventScrollOnSwipe: true,
delta: 10,
});
const resetSwipe = () => {
setSwipeOffset(0);
setIsSwiping(false);
};
return (
<article className="magazine-card group cursor-pointer flex flex-col h-full relative overflow-visible shadow-xl shadow-paper-muted/20 hover:shadow-2xl hover:shadow-nature/20 transition-all duration-500 rounded-xl border border-transparent hover:border-nature/10" onClick={() => onClick(article)}>
{/* Unread Ribbon - Polished Wrapping Effect */}
{!article.is_read && (
<div className="absolute -top-2 -right-2 z-50 w-[100px] h-[100px] overflow-hidden pointer-events-none">
{/* The Ribbon Itself */}
<div className="absolute top-[22px] -right-[35px] w-[140px] bg-nature text-white text-[10px] font-extrabold uppercase tracking-widest py-1.5 text-center transform rotate-45 shadow-lg shadow-black/20 border-b border-white/10 flex items-center justify-center opacity-100 z-50">
Nouveau
<div className="relative overflow-hidden rounded-xl">
{/* Action Layers behind the card */}
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none">
{/* Left side swipe (Mark as Read) */}
<div className={`flex items-center space-x-2 transition-opacity duration-300 ${swipeOffset > 10 ? 'opacity-100' : 'opacity-0'}`}>
<div className="bg-nature text-white p-2 rounded-full shadow-lg">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
{/* Darker Folds for "Wrapping" Effect (Simulated behind the card) */}
<div className="absolute top-0 left-[22px] w-3 h-3 bg-green-900 rotate-45 transform -translate-x-1/2 -translate-y-1/2 z-[-1]"></div>
<div className="absolute bottom-[22px] right-0 w-3 h-3 bg-green-900 rotate-45 transform translate-x-1/2 translate-y-1/2 z-[-1]"></div>
<span className="text-nature font-black text-[10px] uppercase tracking-widest">
{article.is_read ? 'Marquer non lu' : 'Marquer lu'}
</span>
</div>
)}
{!article.is_read && (
/* External fold bits to cover the corner if needed for perfection */
<>
<div className="absolute -top-[5px] right-[62px] w-2 h-2 bg-green-800 rotate-45 z-40 rounded-sm"></div>
<div className="absolute top-[62px] -right-[5px] w-2 h-2 bg-green-800 rotate-45 z-40 rounded-sm"></div>
</>
)}
{/* Image Placeholder with Gradient Overlay */}
<div className="aspect-[16/10] overflow-hidden rounded-t-xl rounded-b-none bg-carbon-light relative mb-6">
{/* Visual placeholder or real image */}
<div
className="w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
style={{
backgroundImage: `url(${article.image_url || 'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800'})`,
backgroundColor: '#f0f0f0'
}}
></div>
{/* Meta on top of image */}
<div className="absolute bottom-4 left-4 right-4 z-20 flex justify-between items-end">
<span className="bg-white px-2 py-1 rounded shadow-sm border border-nature/5 text-[10px] text-nature font-black uppercase tracking-[.25em] drop-shadow-sm">
{article.feed_title || 'Journal'}
</span>
<span className="bg-white px-2 py-1 rounded shadow-sm border border-nature/5 text-[10px] text-nature/80 font-black uppercase tracking-widest px-2 py-1 rounded shadow-sm border border-nature/5 drop-shadow-sm">
{timeToRead} min
</span>
{/* Right side swipe (Favorite) */}
<div className={`flex items-center space-x-2 transition-opacity duration-300 ${swipeOffset < -10 ? 'opacity-100' : 'opacity-0'}`}>
<span className="text-earth font-black text-[10px] uppercase tracking-widest">Favori</span>
<div className="bg-earth text-white p-2 rounded-full shadow-lg">
<svg className="w-6 h-6" fill={article.is_favorite ? "currentColor" : "none"} viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.382-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 flex flex-col px-1 gap-4">
<h2 className={`text-xl font-serif transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-black'}`}
style={article.is_read ? {} : { textShadow: '0 1px 1px rgba(0,0,0,0.05)' }}
>
{article.title}
</h2>
{/* Main Interactive Card */}
<article
{...handlers}
className={`magazine-card group cursor-pointer flex flex-col h-full relative z-10 bg-carbon overflow-visible shadow-xl shadow-paper-muted/20 hover:shadow-2xl hover:shadow-nature/20 rounded-xl border border-transparent hover:border-nature/10 transition-transform ${isSwiping ? '' : 'duration-500 ease-out'}`}
style={{
transform: `translateX(${swipeOffset}px)`
}}
onClick={() => !isSwiping && Math.abs(swipeOffset) < 5 && onClick(article)}
>
{article.summary && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-nature/0 group-hover:opacity-100 transition-opacity">
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}...
</p>
{/* Unread Ribbon - Polished Wrapping Effect */}
{!article.is_read && (
<div className="absolute -top-2 -right-2 z-50 w-[100px] h-[100px] overflow-hidden pointer-events-none">
{/* The Ribbon Itself */}
<div className="absolute top-[22px] -right-[35px] w-[140px] bg-nature text-white text-[10px] font-extrabold uppercase tracking-widest py-1.5 text-center transform rotate-45 shadow-lg shadow-black/20 border-b border-white/10 flex items-center justify-center opacity-100 z-50">
Nouveau
</div>
{/* Darker Folds for "Wrapping" Effect (Simulated behind the card) */}
<div className="absolute top-0 left-[22px] w-3 h-3 bg-green-900 rotate-45 transform -translate-x-1/2 -translate-y-1/2 z-[-1]"></div>
<div className="absolute bottom-[22px] right-0 w-3 h-3 bg-green-900 rotate-45 transform translate-x-1/2 translate-y-1/2 z-[-1]"></div>
</div>
)}
{!article.is_read && (
/* External fold bits to cover the corner if needed for perfection */
<>
<div className="absolute -top-[5px] right-[62px] w-2 h-2 bg-green-800 rotate-45 z-40 rounded-sm"></div>
<div className="absolute top-[62px] -right-[5px] w-2 h-2 bg-green-800 rotate-45 z-40 rounded-sm"></div>
</>
)}
<div className="mt-auto flex items-center justify-between pt-6 pb-4 border-t border-white/5">
<div className="flex space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
onToggleRead(article.id, !article.is_read);
}}
className={`p-1.5 rounded-full transition-all duration-300 border ${article.is_read ? 'text-nature/40 border-nature/10' : 'text-nature bg-nature/5 border-paper-white/40 hover:bg-nature/20 hover:border-nature'}`}
title={article.is_read ? "Marquer comme non lu" : "Marquer comme lu"}
>
<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-nature nature-glow' : 'text-paper-muted hover:text-nature hover:bg-nature/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>
{/* Image Placeholder with Gradient Overlay */}
<div className="aspect-[16/10] overflow-hidden rounded-t-xl rounded-b-none bg-carbon-light relative mb-6">
<span className="text-[10px] text-paper-muted/60 font-medium tracking-wide">
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}
</span>
{/* Visual placeholder or real image */}
<div
className="w-full h-full bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
style={{
backgroundImage: `url(${article.image_url || 'https://images.unsplash.com/photo-1586339949916-3e9457bef6d3?auto=format&fit=crop&q=80&w=800'})`,
backgroundColor: '#f0f0f0'
}}
></div>
{/* Meta on top of image */}
<div className="absolute bottom-4 left-4 right-4 z-20 flex justify-between items-end">
<span className="bg-white px-2 py-1 rounded shadow-sm border border-nature/5 text-[10px] text-nature font-black uppercase tracking-[.25em] drop-shadow-sm">
{article.feed_title || 'Journal'}
</span>
</div>
</div>
</div>
</article>
{/* Content */}
<div className="flex-1 flex flex-col px-1 gap-4">
<h2 className={`text-xl font-serif transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-black'}`}
style={article.is_read ? {} : { textShadow: '0 1px 1px rgba(0,0,0,0.05)' }}
>
{article.title}
</h2>
{article.summary && (
<p className="text-paper-muted text-sm leading-relaxed line-clamp-3 font-reading opacity-80 decoration-nature/0 group-hover:opacity-100 transition-opacity">
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 160)}...
</p>
)}
<div className="mt-auto flex items-center justify-between pt-6 pb-4 border-t border-white/5">
<div className="flex space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
onToggleRead(article.id, !article.is_read);
}}
className={`p-1.5 rounded-full transition-all duration-300 border ${article.is_read ? 'text-nature/40 border-nature/10' : 'text-nature bg-nature/5 border-paper-white/40 hover:bg-nature/20 hover:border-nature'}`}
title={article.is_read ? "Marquer comme non lu" : "Marquer comme lu"}
>
<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-nature nature-glow' : 'text-paper-muted hover:text-nature hover:bg-nature/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>
<span className="text-[10px] text-paper-muted/60 font-medium tracking-wide">
{new Date(article.published_at || article.created_at).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
</div>
</article>
</div>
);
}
+12 -9
View File
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { feedsApi } from '../api/feeds';
import { articlesApi } from '../api/articles';
import { AddFeedModal } from './AddFeedModal';
import { ThemeToggle } from './ThemeToggle';
interface SidebarProps {
onSelectFeed: (feedId: string | null) => void;
@@ -65,10 +66,9 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
return (
<>
<aside className="w-72 hidden md:flex bg-carbon-dark/40 backdrop-blur-xl border-r border-earth/10 h-screen flex-col z-20">
<aside className="w-72 hidden md:flex bg-carbon-dark/40 backdrop-blur-xl border-r border-earth/10 h-screen flex-col z-20 transition-colors duration-500">
<div className="p-8 pb-4">
<div className="flex items-center justify-between mb-8">
<h1
className="text-nature text-3xl font-serif italic tracking-tight cursor-pointer hover:opacity-80 transition-opacity"
style={{ textShadow: '1px 1px 2px rgba(100, 100, 100, 0.2)' }}
@@ -76,13 +76,16 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
>
FlowReader
</h1>
<button
onClick={() => setIsAddModalOpen(true)}
className="w-8 h-8 flex items-center justify-center rounded-full border border-nature/20 text-nature hover:bg-nature hover:text-white transition-all duration-300 shadow-sm"
title="Ajouter un flux"
>
<span className="text-xl leading-none">+</span>
</button>
<div className="flex items-center space-x-2">
<ThemeToggle />
<button
onClick={() => setIsAddModalOpen(true)}
className="w-8 h-8 flex items-center justify-center rounded-full border border-nature/20 text-nature hover:bg-nature hover:text-white transition-all duration-300 shadow-sm"
title="Ajouter un flux"
>
<span className="text-xl leading-none">+</span>
</button>
</div>
</div>
<nav className="space-y-1">
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');
useEffect(() => {
const root = window.document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<button
onClick={toggleTheme}
className="p-2 rounded-lg border border-nature/10 text-nature hover:bg-nature/5 transition-all duration-300"
title={theme === 'light' ? 'Activer le mode sombre' : 'Activer le mode clair'}
>
{theme === 'light' ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</button>
);
}
+34 -1
View File
@@ -7,6 +7,8 @@
--color-carbon-dark: #E7E5E4;
--color-nature: #166534;
--color-nature-light: #15803d;
--color-nature-dark: #0A0F0B;
/* Deep Forest Base */
--color-earth: #A0522D;
--color-paper-white: #1C1917;
--color-paper-muted: #57534E;
@@ -18,16 +20,37 @@
--line-height-magazine: 1.8;
}
/* Dark Mode Variables */
.dark {
--color-carbon: #0A0F0B;
--color-carbon-light: #141C16;
--color-carbon-dark: #050705;
--color-nature: #22C55E;
--color-nature-light: #4ADE80;
--color-paper-white: #F9F7F1;
--color-paper-muted: #A8A29E;
}
@utility nature-glow {
box-shadow: 0 4px 20px rgba(22, 101, 52, 0.15);
}
.dark .nature-glow {
box-shadow: 0 4px 25px rgba(34, 197, 94, 0.2);
}
@utility paper-panel {
background: #FFFFFF;
border: 1px solid #E7E5E4;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
.dark .paper-panel {
background: var(--color-carbon-light);
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
}
@layer base {
body {
@apply bg-carbon text-paper-white font-sans antialiased selection:bg-nature/20;
@@ -38,6 +61,12 @@
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.2'/%3E%3C/svg%3E");
background-blend-mode: multiply;
background-repeat: repeat;
transition: background-color 0.5s ease, color 0.5s ease;
}
.dark body {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.05'/%3E%3C/svg%3E");
background-blend-mode: screen;
}
h1,
@@ -104,7 +133,11 @@
}
.input-field {
@apply bg-white border border-carbon-dark text-paper-white px-4 py-3 rounded-lg focus:border-nature/50 focus:ring-1 focus:ring-nature/50 outline-none transition-all placeholder:text-paper-muted/50 shadow-sm;
@apply bg-white border border-carbon-dark text-paper-white px-4 py-3 rounded-lg focus:border-nature/50 focus:ring-1 focus:ring-nature/50 outline-none transition-all placeholder:text-paper-muted/50 shadow-sm transition-all;
}
.dark .input-field {
@apply bg-carbon-light border-white/5 text-paper-white placeholder:text-paper-muted/30;
}
.magazine-content {
+3
View File
@@ -1,8 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { registerSW } from 'virtual:pwa-register'
import './index.css'
import App from './App.tsx'
registerSW({ immediate: true })
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+68 -24
View File
@@ -31,9 +31,19 @@ function useIsMobile() {
export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
const queryClient = useQueryClient();
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const isMobile = useIsMobile();
const loadMoreRef = useRef<HTMLDivElement>(null);
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery]);
// Real-time sync
useWebsocket();
@@ -44,12 +54,17 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['articles', selectedFeedId],
queryKey: ['articles', selectedFeedId, debouncedQuery],
queryFn: ({ pageParam = 0 }) => {
const options = {
limit: ITEMS_PER_PAGE,
offset: pageParam,
};
if (debouncedQuery) {
return articlesApi.search(debouncedQuery, ITEMS_PER_PAGE, pageParam);
}
if (selectedFeedId === 'favorites') {
return articlesApi.list({ ...options, favorite: true });
}
@@ -93,10 +108,11 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
onMutate: async ({ id, is_read }) => {
await queryClient.cancelQueries({ queryKey: ['articles'] });
const previousData = queryClient.getQueryData(['articles', selectedFeedId]);
const queryKey = ['articles', selectedFeedId, debouncedQuery];
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
queryClient.setQueryData(['articles', selectedFeedId], (old: any) => {
queryClient.setQueryData(queryKey, (old: any) => {
if (!old) return old;
return {
...old,
@@ -111,8 +127,9 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
return { previousData };
},
onError: (_err, _newTodo, context) => {
const queryKey = ['articles', selectedFeedId, debouncedQuery];
if (context?.previousData) {
queryClient.setQueryData(['articles', selectedFeedId], context.previousData);
queryClient.setQueryData(queryKey, context.previousData);
}
},
onSuccess: () => {
@@ -169,16 +186,16 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
<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">
<header className="mb-20 space-y-12 border-b border-white/5 pb-10">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-nature text-[10px] uppercase tracking-[0.5em] font-black">Édition du jour</p>
<h1 className="text-6xl font-serif italic text-paper-white tracking-tighter">
{selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'}
{debouncedQuery ? `Résultats : "${debouncedQuery}"` : selectedFeedId === 'favorites' ? 'Mes Favoris' : selectedFeedId ? 'Archives du Flux' : 'La Une'}
</h1>
</div>
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex flex-col items-center">
<div className="flex flex-col items-end gap-6">
<div className="flex flex-col items-end text-right">
<p className="text-nature text-sm font-serif italic font-bold tracking-wide">
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
@@ -186,13 +203,39 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
Volume IX • No. 42
</p>
</div>
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className={`text-[10px] uppercase tracking-[0.2em] font-bold px-6 py-2.5 rounded-full border border-nature/30 text-nature hover:bg-nature hover:text-white hover:border-nature transition-all duration-300 ${refreshMutation.isPending ? 'animate-pulse opacity-50' : 'hover:shadow-lg hover:shadow-nature/20'}`}
>
{refreshMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
</button>
<div className="flex items-center gap-3">
<div className="relative group">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-paper-muted/40 group-focus-within:text-nature transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="RECHERCHER DANS L'ÉDITION..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-full text-[10px] text-paper-white placeholder-paper-muted/40 font-black tracking-widest focus:outline-none focus:ring-1 focus:ring-nature focus:border-nature transition-all w-48 focus:w-72"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute inset-y-0 right-3 flex items-center text-paper-muted hover:text-paper-white transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className={`text-[10px] uppercase tracking-[0.2em] font-bold px-6 py-2 rounded-full border border-nature/30 text-nature hover:bg-nature hover:text-white hover:border-nature transition-all duration-300 ${refreshMutation.isPending ? 'animate-pulse opacity-50' : 'hover:shadow-lg hover:shadow-nature/20'}`}
>
{refreshMutation.isPending ? 'Mise à jour' : 'Actualiser'}
</button>
</div>
</div>
</div>
</header>
@@ -223,7 +266,7 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
{isFetchingNextPage ? (
<>
<div className="w-8 h-8 border-t-2 border-nature rounded-full animate-spin"></div>
<p className="text-nature text-[8px] uppercase tracking-[0.4em] font-black">Chargement d'articles supplémentaires...</p>
<p className="text-nature text-[8px] uppercase tracking-[0.4em] font-black">Chargement supplémentaire...</p>
</>
) : hasNextPage ? (
<p className="text-paper-muted/20 text-[8px] uppercase tracking-[0.4em] font-black italic">Défilez pour découvrir la suite</p>
@@ -238,8 +281,12 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
</>
) : (
<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>
<p className="text-paper-muted font-serif italic text-xl mb-4">
{debouncedQuery ? `Aucun article ne correspond à "${debouncedQuery}".` : 'Votre bibliothèque est vide.'}
</p>
<p className="text-paper-muted/40 text-[10px] uppercase tracking-widest font-bold">
{debouncedQuery ? "Tentez une recherche plus large." : "Ajoutez un flux pour commencer votre lecture."}
</p>
</div>
)}
</div>
@@ -260,10 +307,7 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
toggleReadMutation.mutate({ id: nextArticle.id, is_read: true });
}
} else if (hasNextPage) {
fetchNextPage().then(() => {
// Logic to select next after fetch could be here,
// but usually we wait for state update.
});
fetchNextPage();
} else {
setSelectedArticle(null);
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
+54 -1
View File
@@ -1,9 +1,62 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'FlowReader',
short_name: 'FlowReader',
description: 'Lecteur RSS minimaliste et rapide',
theme_color: '#166534',
background_color: '#166534',
display: 'standalone',
icons: [
{
src: 'favicon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/images\.unsplash\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'unsplash-images',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 Days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /\/api\/v1\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 Hours
},
},
},
],
},
})
],
server: {
port: 5173,
proxy: {