mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat: implement Deep Forest dark mode, swipe gestures, PWA support, and PostgreSQL Full-Text Search
This commit is contained in:
1 parent
3e468d8885
commit
7e802df2a7
18 files changed
+4703
-31
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+4274
-1
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import type { Article } from '../api/articles';
|
||||
|
||||
interface ArticleCardProps {
|
||||
@@ -8,10 +10,77 @@ 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)}>
|
||||
<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>
|
||||
<span className="text-nature font-black text-[10px] uppercase tracking-widest">
|
||||
{article.is_read ? 'Marquer non lu' : 'Marquer lu'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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)}
|
||||
>
|
||||
|
||||
{/* Unread Ribbon - Polished Wrapping Effect */}
|
||||
{!article.is_read && (
|
||||
@@ -52,9 +121,6 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,5 +177,6 @@ export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +76,8 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
|
||||
>
|
||||
FlowReader
|
||||
</h1>
|
||||
<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"
|
||||
@@ -84,6 +86,7 @@ export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
|
||||
<span className="text-xl leading-none">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
<button
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,15 +203,41 @@ export function DashboardPage({ selectedFeedId }: DashboardPageProps) {
|
||||
Volume IX • No. 42
|
||||
</p>
|
||||
</div>
|
||||
<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.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'}`}
|
||||
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...' : 'Mettre à jour'}
|
||||
{refreshMutation.isPending ? 'Mise à jour' : 'Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Grid */}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
+54
-1
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user