# Conflicts:
#	web/package-lock.json
This commit is contained in:
Michael committed 2026-02-12 11:57:42 +01:00
commit 04a76795a4
28 files changed
+5556 -1367

No files matched your search

+2 -2
View File
@@ -115,8 +115,8 @@ L'objectif est une expérience de lecture complète et fiable pour plusieurs uti
### Reading Experience
* **FR8**: Dashboard unifié "Nouveautés" trié par date.
* **FR9**: Mode "Lecture Plaisir" avec extraction du contenu principal (nettoyage HTML).
* **FR8**: Dashboard unifié "Nouveautés" trié par date avec **scroll infini** pour une navigation fluide.
* **FR9**: Mode "Lecture Plaisir" avec extraction du contenu principal et **typographie optimisée** (suppression de l'italique sur les titres pour un meilleur confort visuel).
* **FR10**: Marquage "Lu" manuel ou automatique (scroll/swipe).
* **FR11**: Marquage "Tout lu" par flux ou global.
* **FR12**: Gestion des Favoris ("Star" / "Read Later").
+9 -2
View File
@@ -46,6 +46,7 @@ func main() {
// Initialize services
authService := service.NewAuthService(userRepo, sessionRepo)
feedService := service.NewFeedService(feedRepo)
aiService := service.NewAIService()
// Initialize WS Hub
hub := ws.NewHub()
@@ -56,15 +57,19 @@ func main() {
// Initialize handlers
authHandler := handler.NewAuthHandler(authService)
feedHandler := handler.NewFeedHandler(feedService, fetchService, authService)
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, hub)
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, aiService, hub)
wsHandler := handler.NewWSHandler(hub, authService)
adminHandler := handler.NewAdminHandler(userRepo, authService)
// Start background feed fetcher
// Start background workers
fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5)
fetcher.Start()
defer fetcher.Stop()
cleaner := worker.NewCleaner(articleRepo, 24*time.Hour)
cleaner.Start()
defer cleaner.Stop()
// Initialize router
r := chi.NewRouter()
@@ -122,12 +127,14 @@ 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)
r.Post("/{id}/read", articleHandler.MarkRead)
r.Delete("/{id}/read", articleHandler.MarkUnread)
r.Post("/{id}/favorite", articleHandler.ToggleFavorite)
r.Post("/{id}/summarize", articleHandler.Summarize)
})
// WebSocket route
+1
View File
@@ -11,6 +11,7 @@ services:
environment:
- PORT=8080
- DATABASE_URL=postgres://flowreader:flowreader@db:5432/flowreader?sslmode=disable
- OPENROUTER_API_KEY=votre_clef_ici
depends_on:
db:
condition: service_healthy
+63 -16
View File
@@ -42,27 +42,54 @@ func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
// RunMigrations executes pending database migrations.
func RunMigrations(ctx context.Context, pool *pgxpool.Pool) error {
// Check if users table exists
var exists bool
err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'users'
// 1. Create schema_migrations table if it doesn't exist
_, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`).Scan(&exists)
`)
if err != nil {
return fmt.Errorf("checking users table: %w", err)
return fmt.Errorf("creating schema_migrations table: %w", err)
}
if exists {
log.Println("Database already initialized")
return nil
// 2. Get applied migrations
rows, err := pool.Query(ctx, "SELECT version FROM schema_migrations")
if err != nil {
return fmt.Errorf("querying applied migrations: %w", err)
}
defer rows.Close()
applied := make(map[string]bool)
for rows.Next() {
var version string
if err := rows.Scan(&version); err != nil {
return fmt.Errorf("scanning migration version: %w", err)
}
applied[version] = true
}
log.Println("Initializing database and applying migrations...")
// 3. Special case for legacy system:
// If 'users' table exists but 'schema_migrations' is empty,
// mark all migrations up to 005 as applied to avoid duplicate creation errors.
if len(applied) == 0 {
var usersExists bool
pool.QueryRow(ctx, "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users')").Scan(&usersExists)
if usersExists {
log.Println("Legacy database detected, bootstrapping migration tracking...")
// We assume 001 to 005 are already there if users table exists in the old system
legacyVersions := []string{"001_create_users.up.sql", "002_create_sessions.up.sql", "003_create_feeds.up.sql", "004_add_user_roles.up.sql", "005_search_articles.up.sql"}
for _, v := range legacyVersions {
_, err := pool.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", v)
if err != nil {
log.Printf("Warning: failed to bootstrap version %s: %v", v, err)
}
applied[v] = true
}
}
}
// Read migration files
// 4. Read migration files
entries, err := os.ReadDir("./migrations")
if err != nil {
return fmt.Errorf("reading migrations dir: %w", err)
@@ -76,18 +103,38 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool) error {
}
sort.Strings(upFiles)
// 5. Apply pending migrations
for _, file := range upFiles {
if applied[file] {
continue
}
log.Printf("Applying migration: %s", file)
content, err := os.ReadFile(filepath.Join("./migrations", file))
if err != nil {
return fmt.Errorf("reading migration %s: %w", file, err)
}
if _, err := pool.Exec(ctx, string(content)); err != nil {
// Execute in transaction
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("starting transaction for %s: %w", file, err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, string(content)); err != nil {
return fmt.Errorf("executing migration %s: %w", file, err)
}
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", file); err != nil {
return fmt.Errorf("recording migration %s: %w", file, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("committing migration %s: %w", file, err)
}
}
log.Println("All migrations applied successfully")
log.Println("Database schema is up to date")
return nil
}
+3
View File
@@ -15,6 +15,7 @@ type Article struct {
URL string `json:"url,omitempty"`
Content string `json:"content,omitempty"`
Summary string `json:"summary,omitempty"`
AISummary string `json:"ai_summary,omitempty"`
Author string `json:"author,omitempty"`
ImageURL string `json:"image_url,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
@@ -42,4 +43,6 @@ 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)
UpdateAISummary(id uuid.UUID, summary string) error
}
+113 -1
View File
@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"strconv"
@@ -17,17 +18,21 @@ type ArticleHandler struct {
articleRepo domain.ArticleRepository
feedService *service.FeedService
authService *service.AuthService
aiService *service.AIService
sanitizer *utils.ContentSanitizer
extractor *utils.ContentExtractor
hub *ws.Hub
}
// NewArticleHandler creates a new article handler.
func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, hub *ws.Hub) *ArticleHandler {
func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, aiService *service.AIService, hub *ws.Hub) *ArticleHandler {
return &ArticleHandler{
articleRepo: articleRepo,
feedService: feedService,
authService: authService,
aiService: aiService,
sanitizer: utils.NewContentSanitizer(),
extractor: utils.NewContentExtractor(),
hub: hub,
}
}
@@ -355,3 +360,110 @@ 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)
}
// Summarize handles POST /api/v1/articles/{id}/summarize
func (h *ArticleHandler) Summarize(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid article ID")
return
}
article, err := h.articleRepo.GetByID(articleID)
if err != nil || article == nil {
respondError(w, http.StatusNotFound, "Article not found")
return
}
// Verify feed ownership
_, err = h.feedService.GetFeed(article.FeedID, userID)
if err != nil {
respondError(w, http.StatusForbidden, "Access denied")
return
}
// If already summarized, return it
if article.AISummary != "" {
respondJSON(w, http.StatusOK, map[string]string{"summary": article.AISummary})
return
}
// Context for AI is title + content (or summary if content empty)
content := article.Content
if content == "" {
content = article.Summary
}
// Try to extract full content from URL if available
if article.URL != "" {
fullContent, err := h.extractor.Extract(r.Context(), article.URL)
if err == nil && len(fullContent) > len(content) {
content = "--- CONTENU COMPLET EXTRAIT DU SITE WEB ---\n" + fullContent
}
}
aiInput := fmt.Sprintf("Titre: %s\n\nContenu: %s", article.Title, content)
// Summary generation (can be slow, but for this demo/small app we do it synchronously
// or we could use WS to notify when done. Here we follow the simple POST -> String pattern).
summary, err := h.aiService.Summarize(r.Context(), aiInput)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate summary: "+err.Error())
return
}
// Save to DB
if err := h.articleRepo.UpdateAISummary(articleID, summary); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to persist summary")
return
}
// Broadcast update via WebSocket
if h.hub != nil {
h.hub.Broadcast("article_updated", map[string]interface{}{
"id": articleID,
"ai_summary": summary,
})
}
respondJSON(w, http.StatusOK, map[string]string{"summary": summary})
}
+139 -12
View File
@@ -27,8 +27,8 @@ func (r *ArticleRepository) Create(article *domain.Article) error {
ctx := context.Background()
query := `
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, author, image_url, published_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (feed_id, guid) DO NOTHING
`
@@ -40,6 +40,7 @@ func (r *ArticleRepository) Create(article *domain.Article) error {
nullString(article.URL),
nullString(article.Content),
nullString(article.Summary),
nullString(article.AISummary),
nullString(article.Author),
nullString(article.ImageURL),
article.PublishedAt,
@@ -59,8 +60,8 @@ func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error {
batch := &pgx.Batch{}
query := `
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, author, image_url, published_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
INSERT INTO articles (id, feed_id, guid, title, url, content, summary, ai_summary, author, image_url, published_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (feed_id, guid) DO NOTHING
`
@@ -73,6 +74,7 @@ func (r *ArticleRepository) CreateBatch(articles []*domain.Article) error {
nullString(article.URL),
nullString(article.Content),
nullString(article.Summary),
nullString(article.AISummary),
nullString(article.Author),
nullString(article.ImageURL),
article.PublishedAt,
@@ -97,7 +99,7 @@ func (r *ArticleRepository) GetByID(id uuid.UUID) (*domain.Article, error) {
ctx := context.Background()
query := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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
FROM articles a
@@ -121,7 +123,7 @@ func (r *ArticleRepository) GetByFeedID(feedID uuid.UUID, limit, offset int) ([]
ctx := context.Background()
query := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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
FROM articles a
@@ -147,7 +149,7 @@ func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unr
var query string
if unreadOnly {
query = `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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
FROM articles a
@@ -158,7 +160,7 @@ func (r *ArticleRepository) GetByUserID(userID uuid.UUID, limit, offset int, unr
`
} else {
query = `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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
FROM articles a
@@ -183,7 +185,7 @@ func (r *ArticleRepository) GetByGUID(feedID uuid.UUID, guid string) (*domain.Ar
ctx := context.Background()
query := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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
FROM articles a
@@ -269,7 +271,7 @@ func (r *ArticleRepository) GetFavorites(userID uuid.UUID, limit, offset int) ([
ctx := context.Background()
query := `
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.author,
SELECT a.id, a.feed_id, a.guid, a.title, a.url, a.content, a.summary, a.ai_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
FROM articles a
@@ -305,7 +307,7 @@ func (r *ArticleRepository) CountUnread(feedID uuid.UUID) (int, error) {
// scanArticle scans a single article row.
func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
var article domain.Article
var url, content, summary, author, imageURL, feedTitle *string
var url, content, summary, aiSummary, author, imageURL, feedTitle *string
var publishedAt, readAt *time.Time
err := row.Scan(
@@ -316,6 +318,7 @@ func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
&url,
&content,
&summary,
&aiSummary,
&author,
&imageURL,
&publishedAt,
@@ -339,6 +342,9 @@ func (r *ArticleRepository) scanArticle(row pgx.Row) (*domain.Article, error) {
if summary != nil {
article.Summary = *summary
}
if aiSummary != nil {
article.AISummary = *aiSummary
}
if author != nil {
article.Author = *author
}
@@ -359,7 +365,7 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro
var articles []*domain.Article
for rows.Next() {
var article domain.Article
var url, content, summary, author, imageURL, feedTitle *string
var url, content, summary, aiSummary, author, imageURL, feedTitle *string
var publishedAt, readAt *time.Time
err := rows.Scan(
@@ -370,6 +376,7 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro
&url,
&content,
&summary,
&aiSummary,
&author,
&imageURL,
&publishedAt,
@@ -393,6 +400,9 @@ func (r *ArticleRepository) scanArticles(rows pgx.Rows) ([]*domain.Article, erro
if summary != nil {
article.Summary = *summary
}
if aiSummary != nil {
article.AISummary = *aiSummary
}
if author != nil {
article.Author = *author
}
@@ -411,6 +421,123 @@ 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.ai_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, aiSummary, 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,
&aiSummary,
&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 aiSummary != nil {
article.AISummary = *aiSummary
}
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
}
// UpdateAISummary updates the AI-generated summary of an article.
func (r *ArticleRepository) UpdateAISummary(id uuid.UUID, summary string) error {
ctx := context.Background()
query := `UPDATE articles SET ai_summary = $2 WHERE id = $1`
_, err := r.pool.Exec(ctx, query, id, summary)
if err != nil {
return fmt.Errorf("updating AI summary: %w", err)
}
return nil
}
// DeleteOldArticles removes articles older than the specified duration, except for favorites.
func (r *ArticleRepository) DeleteOldArticles(ctx context.Context, olderThan time.Duration) (int64, error) {
threshold := time.Now().Add(-olderThan)
query := `
DELETE FROM articles
WHERE created_at < $1 AND is_favorite = false
`
result, err := r.pool.Exec(ctx, query, threshold)
if err != nil {
return 0, fmt.Errorf("deleting old articles: %w", err)
}
return result.RowsAffected(), nil
}
// nullString returns nil if string is empty.
func nullString(s string) *string {
if s == "" {
+107
View File
@@ -0,0 +1,107 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
// AIService handles interactions with AI providers (OpenRouter).
type AIService struct {
apiKey string
client *http.Client
}
// NewAIService creates a new AI service.
func NewAIService() *AIService {
return &AIService{
apiKey: os.Getenv("OPENROUTER_API_KEY"),
client: &http.Client{},
}
}
// OpenRouterRequest represents the request body for OpenRouter.
type OpenRouterRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
// Message represents a message in the conversation.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// OpenRouterResponse represents the response body from OpenRouter.
type OpenRouterResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// Summarize generates a concise summary of the given content.
func (s *AIService) Summarize(ctx context.Context, content string) (string, error) {
if s.apiKey == "" {
return "", fmt.Errorf("OPENROUTER_API_KEY not set")
}
prompt := fmt.Sprintf("Résume l'article suivant en 3 à 5 phrases percutantes. Sois direct et informatif :\n\n%s", content)
reqBody := OpenRouterRequest{
Model: "google/gemini-2.0-flash-001", // Économique et performant
Messages: []Message{
{Role: "user", Content: prompt},
},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshaling request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.apiKey)
req.Header.Set("HTTP-Referer", "https://github.com/michael/flowreader") // Optionnel pour OpenRouter
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var orResp OpenRouterResponse
if err := json.Unmarshal(body, &orResp); err != nil {
return "", fmt.Errorf("unmarshaling response: %w", err)
}
if orResp.Error != nil {
return "", fmt.Errorf("OpenRouter error: %s", orResp.Error.Message)
}
if len(orResp.Choices) == 0 {
return "", fmt.Errorf("no summary generated")
}
return orResp.Choices[0].Message.Content, nil
}
+118
View File
@@ -0,0 +1,118 @@
package utils
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
)
// ContentExtractor extracts the main text content from a web page.
type ContentExtractor struct {
client *http.Client
}
// NewContentExtractor creates a new extractor instance.
func NewContentExtractor() *ContentExtractor {
return &ContentExtractor{
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// Extract fetches the URL and tries to extract the main article content.
func (e *ContentExtractor) Extract(ctx context.Context, url string) (string, error) {
if url == "" {
return "", fmt.Errorf("empty URL")
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
// Set a common User-Agent to avoid some basic bot detection
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := e.client.Do(req)
if err != nil {
return "", fmt.Errorf("fetching URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return "", fmt.Errorf("parsing HTML: %w", err)
}
// 1. Remove noise
doc.Find("script, style, nav, footer, header, aside, .ads, #comments, .sidebar").Remove()
// 2. Try to find the main content container
var content string
// Priority list of selectors for article content
selectors := []string{
"article",
"main",
".article-content",
".post-content",
".entry-content",
".content",
"#main-content",
}
for _, selector := range selectors {
selection := doc.Find(selector)
if selection.Length() > 0 {
// Get the longest piece of text if multiple matches
maxLength := 0
var bestSelection *goquery.Selection
selection.Each(func(i int, s *goquery.Selection) {
textLen := len(strings.TrimSpace(s.Text()))
if textLen > maxLength {
maxLength = textLen
bestSelection = s
}
})
if bestSelection != nil {
content = e.cleanText(bestSelection.Text())
if len(content) > 500 { // Heuristic: good enough
break
}
}
}
}
// 3. Fallback: Body text if no container found or text too short
if len(content) < 500 {
content = e.cleanText(doc.Find("body").Text())
}
// Limit to 10000 characters to avoid huge payloads to AI
if len(content) > 10000 {
content = content[:10000]
}
return content, nil
}
func (e *ContentExtractor) cleanText(text string) string {
lines := strings.Split(text, "\n")
var cleaned []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
return strings.Join(cleaned, "\n")
}
+76
View File
@@ -0,0 +1,76 @@
package worker
import (
"context"
"log"
"sync"
"time"
"github.com/michael/flowreader/internal/repository"
)
// Cleaner handles periodic database maintenance.
type Cleaner struct {
repo *repository.ArticleRepository
interval time.Duration
stopCh chan struct{}
wg sync.WaitGroup
}
// NewCleaner creates a new database cleaner worker.
func NewCleaner(repo *repository.ArticleRepository, interval time.Duration) *Cleaner {
return &Cleaner{
repo: repo,
interval: interval,
stopCh: make(chan struct{}),
}
}
// Start begins the background maintenance loop.
func (c *Cleaner) Start() {
c.wg.Add(1)
go c.run()
log.Printf("Maintenance worker started (interval: %s)", c.interval)
}
// Stop gracefully stops the cleaner.
func (c *Cleaner) Stop() {
close(c.stopCh)
c.wg.Wait()
log.Println("Maintenance worker stopped")
}
func (c *Cleaner) run() {
defer c.wg.Done()
// Initial cleanup on startup
c.cleanup()
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.cleanup()
case <-c.stopCh:
return
}
}
}
func (c *Cleaner) cleanup() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Delete articles older than 30 days
count, err := c.repo.DeleteOldArticles(ctx, 30*24*time.Hour)
if err != nil {
log.Printf("Maintenance cleanup error: %v", err)
return
}
if count > 0 {
log.Printf("Maintenance: cleaned up %d old articles", count)
}
}
+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);
+4
View File
@@ -0,0 +1,4 @@
-- Migration: 006_add_ai_summary
-- Description: Add ai_summary column to articles table
ALTER TABLE articles ADD COLUMN IF NOT EXISTS ai_summary TEXT;
+18 -11
View File
@@ -1,13 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<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" />
<meta name="description"
content="FlowReader - Votre édition quotidienne pour une lecture plaisir et sans distractions." />
<title>FlowReader | La Une</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4268 -1149
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -36,6 +36,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"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#166534"/>
<text x="16" y="23" font-family="Playfair Display, serif" font-size="18" font-weight="900" fill="#F9F7F1" text-anchor="middle">F</text>
<path d="M8 26 L24 26" stroke="#F9F7F1" stroke-width="1" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

+21
View File
@@ -17,6 +17,7 @@ export interface Article {
url?: string;
content?: string;
summary?: string;
ai_summary?: string;
author?: string;
image_url?: string;
published_at?: string;
@@ -106,4 +107,24 @@ 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);
},
async summarize(id: string): Promise<{ summary: string }> {
const response = await fetch(`${API_BASE}/articles/${id}/summarize`, {
method: 'POST',
credentials: 'include',
});
return handleResponse(response);
},
};
+199 -91
View File
@@ -1,4 +1,6 @@
import type { Article } from '../api/articles';
import { useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import { type Article, articlesApi } from '../api/articles';
interface ArticleCardProps {
article: Article;
@@ -8,108 +10,214 @@ 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 [isSummarizing, setIsSummarizing] = 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);
};
const handleSummarize = async (e: React.MouseEvent) => {
e.stopPropagation();
if (article.ai_summary || isSummarizing) return;
setIsSummarizing(true);
try {
await articlesApi.summarize(article.id);
// The article will be updated via WebSocket broadcast
} catch (error) {
console.error('Failed to summarize:', error);
alert('Erreur lors de la génération du résumé. Vérifiez votre clé API OpenRouter.');
} finally {
setIsSummarizing(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-visible pb-4">
{/* 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-nature/10 px-2 py-1 rounded text-[10px] text-nature font-black uppercase tracking-[.25em] drop-shadow-sm">
{article.feed_title || 'Journal'}
</span>
<span className="text-[10px] text-nature/80 font-bold uppercase tracking-widest">
{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 italic transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-bold'}`}
style={article.is_read ? {} : { textShadow: '0 1px 1px rgba(0,0,0,0.1)' }}
>
{article.title}
</h2>
{/* Main Interactive Card */}
<article
{...handlers}
className={`magazine-card group cursor-pointer flex flex-col h-full relative z-10 bg-white dark:bg-carbon overflow-visible rounded-xl border border-carbon-dark/30 dark:border-white/5 transition-all ${isSwiping ? '' : 'duration-500 ease-out'} hover:border-nature/20`}
style={{
transform: `translateX(${swipeOffset}px)`,
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.1)'
}}
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/9] overflow-hidden rounded-t-xl rounded-b-none bg-carbon relative mb-4">
<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-5 gap-3">
<h2 className={`text-2xl font-serif leading-snug transition-colors duration-300 group-hover:text-nature-light ${article.is_read ? 'text-paper-muted font-normal' : 'text-paper-white font-black'}`}
>
{article.title}
</h2>
{article.ai_summary ? (
<div className="flex items-center space-x-2 text-nature/80">
<span className="text-sm px-2 py-0.5 bg-nature/10 rounded-full font-medium flex items-center">
<span className="mr-1">✨</span> Résumé
</span>
</div>
) : article.summary && (
<p className="text-paper-muted/90 text-sm leading-relaxed line-clamp-2 font-reading group-hover:text-paper-white transition-colors">
{article.summary.replace(/<[^>]*>?/gm, '').substring(0, 120)}...
</p>
)}
<div className="mt-auto flex items-center justify-between pt-4 pb-5 border-t border-carbon-dark/20 dark: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>
<button
onClick={handleSummarize}
disabled={isSummarizing || !!article.ai_summary}
className={`p-1.5 rounded-full transition-all duration-300 flex items-center justify-center ${article.ai_summary
? 'text-nature bg-nature/10 opacity-50 cursor-default'
: isSummarizing
? 'text-nature animate-pulse'
: 'text-paper-muted hover:text-nature hover:bg-nature/10'}`}
title={article.ai_summary ? "Résumé généré" : "Générer un résumé IA (Smart Digest)"}
>
{isSummarizing ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
) : (
<span className="text-sm leading-none">✨</span>
)}
</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>
);
}
+3 -3
View File
@@ -106,14 +106,14 @@ export function MobileReaderView({ article, onClose, onToggleFavorite, onNext, o
</div>
</div>
{/* Floating Close Button for Mobile - Easier thumb reach */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 pointer-events-none">
{/* Fixed Close Button for Mobile - Top Right */}
<div className="fixed top-6 right-6 z-50 pointer-events-none">
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="w-12 h-12 bg-paper-white text-carbon-light shadow-2xl border border-white/10 rounded-full flex items-center justify-center active:scale-95 transition-all duration-300 pointer-events-auto hover:bg-nature"
className="w-10 h-10 bg-nature text-carbon border border-white/10 rounded-full flex items-center justify-center active:scale-95 transition-all duration-300 pointer-events-auto hover:scale-110 shadow-lg"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+44 -2
View File
@@ -1,4 +1,5 @@
import type { Article } from '../api/articles';
import { useState } from 'react';
import { type Article, articlesApi } from '../api/articles';
interface ReaderViewProps {
article: Article;
@@ -7,6 +8,9 @@ interface ReaderViewProps {
}
export function ReaderView({ article, onClose, onToggleFavorite }: ReaderViewProps) {
const [aiSummary, setAiSummary] = useState(article.ai_summary);
const [isSummarizing, setIsSummarizing] = useState(false);
// Determine content to show: full content > summary > default message
let displayContent = article.content || article.summary || '<p class="italic text-paper-muted">Aucun contenu disponible pour cet article.</p>';
@@ -59,9 +63,47 @@ export function ReaderView({ article, onClose, onToggleFavorite }: ReaderViewPro
<span>{article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Aujourd\'hui'}</span>
</div>
<h1 className="text-4xl md:text-6xl font-serif text-paper-white leading-[1.15] italic-nature tracking-tight">
<h1 className="text-4xl md:text-6xl font-serif text-paper-white leading-[1.15] italic-nature tracking-tight mb-8">
{article.title}
</h1>
{/* Smart Digest Section */}
{aiSummary ? (
<div className="bg-nature/5 border-l-4 border-nature p-6 rounded-r-2xl mb-12 animate-fade-in shadow-sm">
<h3 className="text-nature font-black text-[10px] uppercase tracking-[0.2em] mb-4 flex items-center">
<span className="mr-2 text-base">✨</span> Résumé
</h3>
<p className="text-paper-white text-lg leading-relaxed font-reading italic">
{aiSummary}
</p>
</div>
) : (
<div className="mb-12">
<button
onClick={async (e) => {
e.stopPropagation();
setIsSummarizing(true);
try {
const res = await articlesApi.summarize(article.id);
setAiSummary(res.summary);
} catch (err) {
console.error("Failed to summarize:", err);
} finally {
setIsSummarizing(false);
}
}}
disabled={isSummarizing}
className={`group flex items-center space-x-3 bg-nature/5 hover:bg-nature text-nature hover:text-white border border-nature/20 px-6 py-3 rounded-full transition-all duration-500 ${isSummarizing ? 'opacity-50 cursor-wait' : ''}`}
>
<span className={`text-xl transition-transform ${isSummarizing ? 'animate-spin' : 'group-hover:rotate-12'}`}>
{isSummarizing ? '⏳' : '✨'}
</span>
<span className="text-[10px] uppercase tracking-[0.2em] font-black">
{isSummarizing ? 'Génération en cours...' : 'Générer le résumé'}
</span>
</button>
</div>
)}
</header>
<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;
@@ -67,10 +68,9 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
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)' }}
@@ -78,13 +78,16 @@ export function Sidebar({ onSelectFeed, selectedFeedId, onEnterFocus, isFocusMod
>
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/10 hover:text-nature-light active:scale-95 transition-all duration-300 shadow-sm"
title="Ajouter un flux"
>
<span className="text-xl leading-none" style={{ marginBottom: '1px' }}>+</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="w-8 h-8 flex items-center justify-center rounded-full 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>
);
}
+35 -2
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 {
@@ -121,7 +154,7 @@
.magazine-content h2,
.magazine-content h3 {
@apply text-paper-white font-serif italic mb-6 mt-12;
@apply text-paper-white font-serif mb-6 mt-12;
}
.magazine-content img {
+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 />
+198 -65
View File
@@ -1,12 +1,11 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useEffect, useRef } from 'react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { articlesApi } from '../api/articles';
import { feedsApi } from '../api/feeds';
import { ArticleCard } from '../components/ArticleCard';
import { ReaderView } from '../components/ReaderView';
import { MobileReaderView } from '../components/MobileReaderView';
import { useWebsocket } from '../hooks/useWebsocket';
import { useEffect } from 'react';
import type { Article } from '../api/articles';
interface DashboardPageProps {
@@ -14,6 +13,8 @@ interface DashboardPageProps {
onEnterFocus: () => void;
}
const ITEMS_PER_PAGE = 24;
// Helper Hook for Responsive Split
function useIsMobile() {
const [isMobile, setIsMobile] = useState(window.matchMedia('(max-width: 768px)').matches);
@@ -21,7 +22,7 @@ function useIsMobile() {
useEffect(() => {
const media = window.matchMedia('(max-width: 768px)');
const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches);
media.addEventListener('change', listener); // Modern browsers support this
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, []);
@@ -31,26 +32,75 @@ function useIsMobile() {
export function DashboardPage({ selectedFeedId, onEnterFocus }: 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();
const { data: articles, isLoading } = useQuery({
queryKey: ['articles', selectedFeedId],
queryFn: () => {
if (selectedFeedId === 'favorites') {
return articlesApi.list({ favorite: true });
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
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);
}
return articlesApi.list({ feed_id: selectedFeedId || undefined });
if (selectedFeedId === 'favorites') {
return articlesApi.list({ ...options, favorite: true });
}
return articlesApi.list({ ...options, feed_id: selectedFeedId || undefined });
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < ITEMS_PER_PAGE) return undefined;
return allPages.length * ITEMS_PER_PAGE;
},
});
const articles = data?.pages.flat() || [];
// Intersection Observer for Infinite Scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage, isFetchingNextPage]);
const refreshMutation = useMutation({
mutationFn: () => feedsApi.refresh(),
onSuccess: () => {
// Optimistic feedback or just wait for WS?
// In case WS fails, we invalid articles
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
@@ -59,30 +109,31 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
mutationFn: ({ id, is_read }: { id: string; is_read: boolean }) =>
is_read ? articlesApi.markRead(id) : articlesApi.markUnread(id),
onMutate: async ({ id, is_read }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['articles'] });
const queryKey = ['articles', selectedFeedId, debouncedQuery];
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
// Snapshot previous value
const previousArticles = queryClient.getQueryData(['articles', selectedFeedId]);
// Optimistically update
queryClient.setQueryData(['articles', selectedFeedId], (old: Article[] | undefined) => {
queryClient.setQueryData(queryKey, (old: any) => {
if (!old) return old;
return old.map((article) =>
article.id === id ? { ...article, is_read: is_read } : article
);
return {
...old,
pages: old.pages.map((page: Article[]) =>
page.map((article) =>
article.id === id ? { ...article, is_read: is_read } : article
)
),
};
});
return { previousArticles };
return { previousData };
},
onError: (_err, _newTodo, context) => {
// Rollback on error
if (context?.previousArticles) {
queryClient.setQueryData(['articles', selectedFeedId], context.previousArticles);
const queryKey = ['articles', selectedFeedId, debouncedQuery];
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
},
onSuccess: () => {
// Refetch to be safe (but optimistic update makes it instant)
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
@@ -92,43 +143,100 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articles'] }),
});
const [showScrollTop, setShowScrollTop] = useState(false);
const mainRef = useRef<HTMLElement>(null);
// Visibility logic for "Back to Top" button
useEffect(() => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
setShowScrollTop(target.scrollTop > 1000);
};
const mainElement = mainRef.current;
if (mainElement) {
mainElement.addEventListener('scroll', handleScroll);
}
return () => mainElement?.removeEventListener('scroll', handleScroll);
}, []);
const scrollToTop = () => {
mainRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
};
if (isLoading) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-carbon space-y-4">
<div className="w-12 h-12 border-t-2 border-gold rounded-full animate-spin"></div>
<div className="w-12 h-12 border-t-2 border-nature rounded-full animate-spin"></div>
<p className="text-paper-muted text-[10px] uppercase tracking-[0.4em] font-black">Composition de votre édition...</p>
</div>
);
}
return (
<main className="flex-1 overflow-y-auto bg-carbon scroll-smooth">
<main ref={mainRef} className="flex-1 overflow-y-auto bg-carbon scroll-smooth relative">
{/* Back to Top Button */}
<button
onClick={scrollToTop}
className={`fixed bottom-8 right-12 z-40 p-4 rounded-full bg-white border border-nature/10 text-nature shadow-2xl transition-all duration-500 hover:scale-110 active:scale-95 ${showScrollTop ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0 pointer-events-none'}`}
title="Retour en haut"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
<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-gold text-[10px] uppercase tracking-[0.5em] font-black">Édition du jour</p>
<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">
<p className="text-gold text-sm font-serif italic font-bold tracking-wide">
<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>
<p className="text-[10px] text-paper-muted opacity-80 uppercase tracking-[0.25em] font-black mt-1">
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-gold/30 text-gold 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>
@@ -169,28 +277,52 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
)}
{/* Grid */}
{articles && articles.length > 0 ? (
<div className="magazine-grid">
{articles.map((article, index) => (
<div key={article.id} className={index % 6 === 0 ? 'md:col-span-2' : ''}>
<ArticleCard
article={article}
onClick={(article) => {
setSelectedArticle(article);
if (!article.is_read) {
toggleReadMutation.mutate({ id: article.id, is_read: true });
}
}}
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
/>
</div>
))}
</div>
{articles.length > 0 ? (
<>
<div className="magazine-grid">
{articles.map((article, index) => (
<div key={`${article.id}-${index}`} className={index % 6 === 0 ? 'md:col-span-2' : ''}>
<ArticleCard
article={article}
onClick={(article) => {
setSelectedArticle(article);
if (!article.is_read) {
toggleReadMutation.mutate({ id: article.id, is_read: true });
}
}}
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
onToggleFavorite={(id) => toggleFavoriteMutation.mutate(id)}
/>
</div>
))}
</div>
{/* Infinite Scroll Sentinel */}
<div ref={loadMoreRef} className="mt-20 py-10 flex flex-col items-center justify-center space-y-4">
{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 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>
) : (
<div className="flex flex-col items-center space-y-2">
<div className="h-[1px] w-20 bg-nature/20"></div>
<p className="text-paper-muted/40 text-[8px] uppercase tracking-[0.4em] font-black">Fin de votre édition</p>
<div className="h-[1px] w-20 bg-nature/20"></div>
</div>
)}
</div>
</>
) : (
<div className="flex flex-col items-center justify-center py-40 border-2 border-dashed border-white/5 rounded-3xl">
<p className="text-paper-muted font-serif italic text-xl mb-4">Votre bibliothèque est vide.</p>
<p className="text-paper-muted/40 text-[10px] uppercase tracking-widest font-bold">Ajoutez un flux pour commencer votre lecture.</p>
<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>
@@ -210,9 +342,10 @@ export function DashboardPage({ selectedFeedId, onEnterFocus }: DashboardPagePro
if (!nextArticle.is_read) {
toggleReadMutation.mutate({ id: nextArticle.id, is_read: true });
}
} else if (hasNextPage) {
fetchNextPage();
} else {
// End of list feedback?
setSelectedArticle(null); // Close for now or loop? Closing feels right for "Done".
setSelectedArticle(null);
}
}}
onPrev={() => {
+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: {