mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
Merge branch 'main' of https://github.com/R0m1k3/FlowReader
# Conflicts: # web/package-lock.json
This commit is contained in:
commit
04a76795a4
28 files changed
+5556
-1367
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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>
|
||||
Generated
+4268
-1149
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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={() => {
|
||||
|
||||
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