mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-20 21:58:11 +02:00
feat(epic-3): implémentation du Dashboard, mode lecture et notifications temps réel
This commit is contained in:
1 parent
1d31de95ec
commit
dc26f22f51
20 files changed
+1237
-107
No files matched your search
@@ -44,17 +44,17 @@ development_status:
|
|||||||
epic-1-retrospective: optional
|
epic-1-retrospective: optional
|
||||||
|
|
||||||
# Epic 2: Feed Core Engine
|
# Epic 2: Feed Core Engine
|
||||||
epic-2: in-progress
|
epic-2: done
|
||||||
2-1-feed-model-add-feed-by-url: done
|
2-1-feed-model-add-feed-by-url: done
|
||||||
2-2-feed-metadata-auto-discovery: done
|
2-2-feed-metadata-auto-discovery: done
|
||||||
2-3-opml-import: done
|
2-3-opml-import: done
|
||||||
2-4-feed-management-crud: done
|
2-4-feed-management-crud: done
|
||||||
2-5-background-feed-fetcher: review
|
2-5-background-feed-fetcher: done
|
||||||
epic-2-retrospective: optional
|
epic-2-retrospective: optional
|
||||||
|
|
||||||
# Epic 3: Lecture Plaisir & Real-Time Experience
|
# Epic 3: Lecture Plaisir & Real-Time Experience
|
||||||
epic-3: backlog
|
epic-3: in-progress
|
||||||
3-1-dashboard-nouveautes: backlog
|
3-1-dashboard-nouveautes: in-progress
|
||||||
3-2-article-detail-reader-mode: backlog
|
3-2-article-detail-reader-mode: backlog
|
||||||
3-3-mark-as-read-manual-auto: backlog
|
3-3-mark-as-read-manual-auto: backlog
|
||||||
3-4-mark-all-as-read: backlog
|
3-4-mark-all-as-read: backlog
|
||||||
|
|||||||
+12
-1
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/michael/flowreader/internal/repository"
|
"github.com/michael/flowreader/internal/repository"
|
||||||
"github.com/michael/flowreader/internal/service"
|
"github.com/michael/flowreader/internal/service"
|
||||||
"github.com/michael/flowreader/internal/worker"
|
"github.com/michael/flowreader/internal/worker"
|
||||||
|
"github.com/michael/flowreader/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -45,12 +46,18 @@ func main() {
|
|||||||
// Initialize services
|
// Initialize services
|
||||||
authService := service.NewAuthService(userRepo, sessionRepo)
|
authService := service.NewAuthService(userRepo, sessionRepo)
|
||||||
feedService := service.NewFeedService(feedRepo)
|
feedService := service.NewFeedService(feedRepo)
|
||||||
fetchService := service.NewFetchService(feedRepo, articleRepo)
|
|
||||||
|
// Initialize WS Hub
|
||||||
|
hub := ws.NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
fetchService := service.NewFetchService(feedRepo, articleRepo, hub)
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
feedHandler := handler.NewFeedHandler(feedService, authService)
|
feedHandler := handler.NewFeedHandler(feedService, authService)
|
||||||
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService)
|
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService)
|
||||||
|
wsHandler := handler.NewWSHandler(hub, authService)
|
||||||
|
|
||||||
// Start background feed fetcher
|
// Start background feed fetcher
|
||||||
fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5)
|
fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5)
|
||||||
@@ -112,12 +119,16 @@ func main() {
|
|||||||
// Article routes
|
// Article routes
|
||||||
r.Route("/articles", func(r chi.Router) {
|
r.Route("/articles", func(r chi.Router) {
|
||||||
r.Get("/", articleHandler.List)
|
r.Get("/", articleHandler.List)
|
||||||
|
r.Post("/read-all", articleHandler.MarkAllReadGlobal)
|
||||||
r.Get("/favorites", articleHandler.GetFavorites)
|
r.Get("/favorites", articleHandler.GetFavorites)
|
||||||
r.Get("/{id}", articleHandler.Get)
|
r.Get("/{id}", articleHandler.Get)
|
||||||
r.Post("/{id}/read", articleHandler.MarkRead)
|
r.Post("/{id}/read", articleHandler.MarkRead)
|
||||||
r.Delete("/{id}/read", articleHandler.MarkUnread)
|
r.Delete("/{id}/read", articleHandler.MarkUnread)
|
||||||
r.Post("/{id}/favorite", articleHandler.ToggleFavorite)
|
r.Post("/{id}/favorite", articleHandler.ToggleFavorite)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// WebSocket route
|
||||||
|
r.Get("/ws", wsHandler.Connect)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
|
|||||||
@@ -10,17 +10,21 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/mmcdole/gofeed v1.3.0 // indirect
|
github.com/mmcdole/gofeed v1.3.0 // indirect
|
||||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
golang.org/x/crypto v0.17.0 // indirect
|
golang.org/x/crypto v0.24.0 // indirect
|
||||||
golang.org/x/net v0.10.0 // indirect
|
golang.org/x/net v0.26.0 // indirect
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.16.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -38,6 +38,7 @@ type ArticleRepository interface {
|
|||||||
MarkAsRead(id uuid.UUID) error
|
MarkAsRead(id uuid.UUID) error
|
||||||
MarkAsUnread(id uuid.UUID) error
|
MarkAsUnread(id uuid.UUID) error
|
||||||
MarkAllAsRead(feedID uuid.UUID) error
|
MarkAllAsRead(feedID uuid.UUID) error
|
||||||
|
MarkAllAsReadGlobal(userID uuid.UUID) error
|
||||||
ToggleFavorite(id uuid.UUID) error
|
ToggleFavorite(id uuid.UUID) error
|
||||||
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
|
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
|
||||||
CountUnread(feedID uuid.UUID) (int, error)
|
CountUnread(feedID uuid.UUID) (int, error)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/michael/flowreader/internal/domain"
|
"github.com/michael/flowreader/internal/domain"
|
||||||
"github.com/michael/flowreader/internal/service"
|
"github.com/michael/flowreader/internal/service"
|
||||||
|
"github.com/michael/flowreader/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ArticleHandler handles article-related HTTP requests.
|
// ArticleHandler handles article-related HTTP requests.
|
||||||
@@ -15,6 +16,7 @@ type ArticleHandler struct {
|
|||||||
articleRepo domain.ArticleRepository
|
articleRepo domain.ArticleRepository
|
||||||
feedService *service.FeedService
|
feedService *service.FeedService
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
|
sanitizer *utils.ContentSanitizer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArticleHandler creates a new article handler.
|
// NewArticleHandler creates a new article handler.
|
||||||
@@ -23,6 +25,7 @@ func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *servic
|
|||||||
articleRepo: articleRepo,
|
articleRepo: articleRepo,
|
||||||
feedService: feedService,
|
feedService: feedService,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
|
sanitizer: utils.NewContentSanitizer(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +142,13 @@ func (h *ArticleHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize content
|
||||||
|
if article.Content != "" {
|
||||||
|
article.Content = h.sanitizer.Sanitize(article.Content)
|
||||||
|
} else if article.Summary != "" {
|
||||||
|
article.Summary = h.sanitizer.Sanitize(article.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, article)
|
respondJSON(w, http.StatusOK, article)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +286,22 @@ func (h *ArticleHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]string{"message": "All articles marked as read"})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "All articles marked as read"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkAllReadGlobal handles POST /api/v1/articles/read-all
|
||||||
|
func (h *ArticleHandler) MarkAllReadGlobal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.getUserFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.articleRepo.MarkAllAsReadGlobal(userID); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to mark all as read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{"message": "All articles marked as read"})
|
||||||
|
}
|
||||||
|
|
||||||
// GetFavorites handles GET /api/v1/articles/favorites
|
// GetFavorites handles GET /api/v1/articles/favorites
|
||||||
func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
|
func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, err := h.getUserFromRequest(r)
|
userID, err := h.getUserFromRequest(r)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/michael/flowreader/internal/service"
|
||||||
|
"github.com/michael/flowreader/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WSHandler handles WebSocket connections.
|
||||||
|
type WSHandler struct {
|
||||||
|
hub *ws.Hub
|
||||||
|
authService *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWSHandler creates a new WS handler.
|
||||||
|
func NewWSHandler(hub *ws.Hub, authService *service.AuthService) *WSHandler {
|
||||||
|
return &WSHandler{
|
||||||
|
hub: hub,
|
||||||
|
authService: authService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect handles WebSocket initiation.
|
||||||
|
func (h *WSHandler) Connect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session_id")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.authService.GetUserByToken(cookie.Value)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Setting up WS for user %s", user.ID)
|
||||||
|
h.hub.ServeWS(user.ID, w, r)
|
||||||
|
}
|
||||||
@@ -235,6 +235,24 @@ func (r *ArticleRepository) MarkAllAsRead(feedID uuid.UUID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkAllAsReadGlobal marks all articles for a user as read.
|
||||||
|
func (r *ArticleRepository) MarkAllAsReadGlobal(userID uuid.UUID) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE articles
|
||||||
|
SET is_read = true, read_at = NOW()
|
||||||
|
WHERE feed_id IN (SELECT id FROM feeds WHERE user_id = $1) AND is_read = false
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.pool.Exec(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marking all articles as read globally: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ToggleFavorite toggles the favorite status of an article.
|
// ToggleFavorite toggles the favorite status of an article.
|
||||||
func (r *ArticleRepository) ToggleFavorite(id uuid.UUID) error {
|
func (r *ArticleRepository) ToggleFavorite(id uuid.UUID) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
+23
-11
@@ -9,21 +9,24 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/michael/flowreader/internal/domain"
|
"github.com/michael/flowreader/internal/domain"
|
||||||
"github.com/michael/flowreader/internal/parser"
|
"github.com/michael/flowreader/internal/parser"
|
||||||
|
"github.com/michael/flowreader/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FetchService handles feed fetching and article ingestion.
|
// FetchService handles fetching and parsing feeds.
|
||||||
type FetchService struct {
|
type FetchService struct {
|
||||||
feedRepo domain.FeedRepository
|
feedRepo domain.FeedRepository
|
||||||
articleRepo domain.ArticleRepository
|
articleRepo domain.ArticleRepository
|
||||||
parser *parser.FeedParser
|
parser *parser.FeedParser
|
||||||
|
hub *ws.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFetchService creates a new fetch service.
|
// NewFetchService creates a new fetch service.
|
||||||
func NewFetchService(feedRepo domain.FeedRepository, articleRepo domain.ArticleRepository) *FetchService {
|
func NewFetchService(feedRepo domain.FeedRepository, articleRepo domain.ArticleRepository, hub *ws.Hub) *FetchService {
|
||||||
return &FetchService{
|
return &FetchService{
|
||||||
feedRepo: feedRepo,
|
feedRepo: feedRepo,
|
||||||
articleRepo: articleRepo,
|
articleRepo: articleRepo,
|
||||||
parser: parser.NewFeedParser(),
|
parser: parser.NewFeedParser(),
|
||||||
|
hub: hub,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ func (s *FetchService) FetchFeed(ctx context.Context, feedID uuid.UUID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse the feed
|
// Parse the feed
|
||||||
parsed, err := s.parser.Parse(ctx, feed.URL, feed.ID)
|
parsedFeed, err := s.parser.Parse(ctx, feed.URL, feed.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Update feed with error
|
// Update feed with error
|
||||||
s.feedRepo.UpdateFetchStatus(feed.ID, time.Now(), err.Error())
|
s.feedRepo.UpdateFetchStatus(feed.ID, time.Now(), err.Error())
|
||||||
@@ -46,18 +49,27 @@ func (s *FetchService) FetchFeed(ctx context.Context, feedID uuid.UUID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update feed metadata
|
// Update feed metadata
|
||||||
feed.Title = parsed.Title
|
feed.Title = parsedFeed.Title
|
||||||
feed.Description = parsed.Description
|
feed.Description = parsedFeed.Description
|
||||||
feed.SiteURL = parsed.SiteURL
|
feed.SiteURL = parsedFeed.SiteURL
|
||||||
feed.ImageURL = parsed.ImageURL
|
feed.ImageURL = parsedFeed.ImageURL
|
||||||
if err := s.feedRepo.Update(feed); err != nil {
|
if err := s.feedRepo.Update(feed); err != nil {
|
||||||
log.Printf("Warning: failed to update feed metadata: %v", err)
|
log.Printf("Warning: failed to update feed metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new articles (ON CONFLICT DO NOTHING handles duplicates)
|
// Ingest articles
|
||||||
if len(parsed.Articles) > 0 {
|
if len(parsedFeed.Articles) > 0 {
|
||||||
if err := s.articleRepo.CreateBatch(parsed.Articles); err != nil {
|
if err := s.articleRepo.CreateBatch(parsedFeed.Articles); err != nil {
|
||||||
return fmt.Errorf("creating articles: %w", err)
|
return fmt.Errorf("ingesting articles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast update
|
||||||
|
if s.hub != nil {
|
||||||
|
s.hub.Broadcast("new_articles", map[string]interface{}{
|
||||||
|
"feed_id": feed.ID,
|
||||||
|
"feed_title": feed.Title,
|
||||||
|
"count": len(parsedFeed.Articles),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Package utils provides common utility functions.
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentSanitizer handles HTML sanitization for articles.
|
||||||
|
type ContentSanitizer struct {
|
||||||
|
policy *bluemonday.Policy
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContentSanitizer creates a new sanitizer with a "UGCPolicy" (safe for user-generated content).
|
||||||
|
func NewContentSanitizer() *ContentSanitizer {
|
||||||
|
// Using UGCPolicy allows common tags (b, i, p, img, etc.) but strips dangerous ones.
|
||||||
|
return &ContentSanitizer{
|
||||||
|
policy: bluemonday.UGCPolicy(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize cleans the HTML content.
|
||||||
|
func (s *ContentSanitizer) Sanitize(html string) string {
|
||||||
|
if html == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.policy.Sanitize(html)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // In production, check origin properly
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event represents a websocket event.
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client represents a connected user via websocket.
|
||||||
|
type Client struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Conn *websocket.Conn
|
||||||
|
Send chan []byte
|
||||||
|
Hub *Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub maintains the set of active clients and broadcasts messages.
|
||||||
|
type Hub struct {
|
||||||
|
// Registered clients by user ID
|
||||||
|
clients map[uuid.UUID][]*Client
|
||||||
|
// Broadcast channel for messages
|
||||||
|
broadcast chan Event
|
||||||
|
// Register requests from clients
|
||||||
|
register chan *Client
|
||||||
|
// Unregister requests from clients
|
||||||
|
unregister chan *Client
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub creates a new hub.
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
broadcast: make(chan Event),
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
clients: make(map[uuid.UUID][]*Client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the hub loop.
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case client := <-h.register:
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[client.ID] = append(h.clients[client.ID], client)
|
||||||
|
h.mu.Unlock()
|
||||||
|
log.Printf("Client registered: %s", client.ID)
|
||||||
|
|
||||||
|
case client := <-h.unregister:
|
||||||
|
h.mu.Lock()
|
||||||
|
clients := h.clients[client.ID]
|
||||||
|
for i, c := range clients {
|
||||||
|
if c == client {
|
||||||
|
h.clients[client.ID] = append(clients[:i], clients[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(h.clients[client.ID]) == 0 {
|
||||||
|
delete(h.clients, client.ID)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
close(client.Send)
|
||||||
|
log.Printf("Client unregistered: %s", client.ID)
|
||||||
|
|
||||||
|
case event := <-h.broadcast:
|
||||||
|
// For now, broadcast simple news to all clients of a specific user or global
|
||||||
|
// But since we need user-specific notifications for feeds, we'd ideally pass UserID in Event
|
||||||
|
// Let's enhance Event struct for this or broadcast to all for now if it's "new articles available"
|
||||||
|
// and let them refetch.
|
||||||
|
|
||||||
|
data, _ := json.Marshal(event)
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
for _, userClients := range h.clients {
|
||||||
|
for _, client := range userClients {
|
||||||
|
select {
|
||||||
|
case client.Send <- data:
|
||||||
|
default:
|
||||||
|
// Close slow connections
|
||||||
|
go func(c *Client) { h.unregister <- c }(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends an event to all connected clients.
|
||||||
|
func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error marshaling broadcast payload: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.broadcast <- Event{
|
||||||
|
Type: eventType,
|
||||||
|
Payload: json.RawMessage(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeWS handles websocket requests.
|
||||||
|
func (h *Hub) ServeWS(userID uuid.UUID, w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WS upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
ID: userID,
|
||||||
|
Conn: conn,
|
||||||
|
Send: make(chan []byte, 256),
|
||||||
|
Hub: h,
|
||||||
|
}
|
||||||
|
h.register <- client
|
||||||
|
|
||||||
|
// Start goroutines for reading and writing
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readPump() {
|
||||||
|
defer func() {
|
||||||
|
c.Hub.unregister <- c
|
||||||
|
c.Conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, _, err := c.Conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
|
log.Printf("WS read error: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// We don't expect messages from client yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writePump() {
|
||||||
|
defer func() {
|
||||||
|
c.Conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-c.Send:
|
||||||
|
if !ok {
|
||||||
|
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := c.Conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(message)
|
||||||
|
|
||||||
|
// Add queued messages to the current writer
|
||||||
|
n := len(c.Send)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
w.Write([]byte{'\n'})
|
||||||
|
w.Write(<-c.Send)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+325
-35
@@ -146,29 +146,144 @@ button[type="submit"]:disabled {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dashboard */
|
/* Dashboard Layout */
|
||||||
.dashboard {
|
.dashboard-layout {
|
||||||
min-height: 100vh;
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item .icon {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item .label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item .badge {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-feed-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-feed-btn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
background: var(--surface);
|
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header h1 {
|
.header-actions {
|
||||||
font-size: 1.25rem;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
background: linear-gradient(135deg, var(--primary) 0%, #a855f7 100%);
|
gap: 1.5rem;
|
||||||
-webkit-background-clip: text;
|
}
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
.mark-all-read-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-read-btn:hover:not(:disabled) {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-all-read-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
@@ -177,52 +292,227 @@ button[type="submit"]:disabled {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info span {
|
.articles-main {
|
||||||
color: var(--text-muted);
|
flex: 1;
|
||||||
font-size: 0.875rem;
|
overflow-y: auto;
|
||||||
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.articles-grid {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Article Card */
|
||||||
|
.article-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-excerpt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.375rem 0.75rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover {
|
.action-btn:hover {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-main {
|
.action-btn.active {
|
||||||
flex: 1;
|
color: #fbbf24;
|
||||||
|
border-color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reader View Overlay */
|
||||||
|
.reader-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
background: var(--background);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 0.5rem;
|
||||||
padding: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.reader-actions {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state h2 {
|
.reader-content {
|
||||||
font-size: 2rem;
|
flex: 1;
|
||||||
margin-bottom: 0.5rem;
|
overflow-y: auto;
|
||||||
|
padding: 3rem 4rem;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.reader-content .meta {
|
||||||
color: var(--text-muted);
|
display: flex;
|
||||||
margin-bottom: 0.25rem;
|
gap: 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state .hint {
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content .title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content .author {
|
||||||
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body h1,
|
||||||
|
.content-body h2,
|
||||||
|
.content-body h3 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading/Empty States */
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content .title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
@@ -255,8 +545,8 @@ button[type="submit"]:disabled {
|
|||||||
.login-card {
|
.login-card {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card h1 {
|
.login-card h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { API_BASE, handleResponse } from './client';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Article {
|
||||||
|
id: string;
|
||||||
|
feed_id: string;
|
||||||
|
guid: string;
|
||||||
|
title: string;
|
||||||
|
url?: string;
|
||||||
|
content?: string;
|
||||||
|
summary?: string;
|
||||||
|
author?: string;
|
||||||
|
image_url?: string;
|
||||||
|
published_at?: string;
|
||||||
|
is_read: boolean;
|
||||||
|
is_favorite: boolean;
|
||||||
|
read_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
feed_title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListArticlesOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
unread?: boolean;
|
||||||
|
favorite?: boolean;
|
||||||
|
feed_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const articlesApi = {
|
||||||
|
async list(options: ListArticlesOptions = {}): Promise<Article[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.limit) params.append('limit', options.limit.toString());
|
||||||
|
if (options.offset) params.append('offset', options.offset.toString());
|
||||||
|
if (options.unread) params.append('unread', 'true');
|
||||||
|
if (options.favorite) params.append('favorite', 'true');
|
||||||
|
|
||||||
|
let url = `${API_BASE}/articles`;
|
||||||
|
if (options.favorite) {
|
||||||
|
url = `${API_BASE}/articles/favorites`;
|
||||||
|
} else if (options.feed_id) {
|
||||||
|
url = `${API_BASE}/feeds/${options.feed_id}/articles`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
url += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse<Article[]>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<Article> {
|
||||||
|
const response = await fetch(`${API_BASE}/articles/${id}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse<Article>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async markRead(id: string): Promise<{ is_read: boolean }> {
|
||||||
|
const response = await fetch(`${API_BASE}/articles/${id}/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async markUnread(id: string): Promise<{ is_read: boolean }> {
|
||||||
|
const response = await fetch(`${API_BASE}/articles/${id}/read`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleFavorite(id: string): Promise<{ is_favorite: boolean }> {
|
||||||
|
const response = await fetch(`${API_BASE}/articles/${id}/favorite`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAllRead(feedId: string): Promise<{ message: string }> {
|
||||||
|
const response = await fetch(`${API_BASE}/feeds/${feedId}/read-all`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAllReadGlobal(): Promise<{ message: string }> {
|
||||||
|
const response = await fetch(`${API_BASE}/articles/read-all`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
+14
-32
@@ -1,48 +1,33 @@
|
|||||||
const API_BASE = '/api/v1';
|
import { API_BASE, handleResponse } from './client';
|
||||||
|
|
||||||
interface RegisterRequest {
|
export interface User {
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoginRequest {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
token: string;
|
token: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegisterResponse {
|
export interface RegisterResponse {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApiError extends Error {
|
|
||||||
constructor(public status: number, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'ApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResponse<T>(response: Response): Promise<T> {
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
|
||||||
throw new ApiError(response.status, error.error || 'Request failed');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
async register(data: RegisterRequest): Promise<RegisterResponse> {
|
async register(data: RegisterRequest): Promise<RegisterResponse> {
|
||||||
const response = await fetch(`${API_BASE}/auth/register`, {
|
const response = await fetch(`${API_BASE}/auth/register`, {
|
||||||
@@ -78,6 +63,3 @@ export const authApi = {
|
|||||||
return handleResponse<User>(response);
|
return handleResponse<User>(response);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ApiError };
|
|
||||||
export type { User, LoginResponse, RegisterResponse };
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const API_BASE = '/api/v1';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||||
|
throw new ApiError(response.status, error.error || 'Request failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { API_BASE, handleResponse } from './client';
|
||||||
|
|
||||||
|
export interface Feed {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
site_url?: string;
|
||||||
|
image_url?: string;
|
||||||
|
last_fetched_at?: string;
|
||||||
|
fetch_error?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
unread_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddFeedRequest {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const feedsApi = {
|
||||||
|
async list(): Promise<Feed[]> {
|
||||||
|
const response = await fetch(`${API_BASE}/feeds`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse<Feed[]>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async add(data: AddFeedRequest): Promise<Feed> {
|
||||||
|
const response = await fetch(`${API_BASE}/feeds`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return handleResponse<Feed>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<Feed> {
|
||||||
|
const response = await fetch(`${API_BASE}/feeds/${id}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse<Feed>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/feeds/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
await handleResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async importOPML(file: File): Promise<{ imported: number; skipped: number; errors?: string[] }> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const response = await fetch(`${API_BASE}/feeds/import/opml`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Article } from '../api/articles';
|
||||||
|
|
||||||
|
interface ArticleCardProps {
|
||||||
|
article: Article;
|
||||||
|
onClick: (article: Article) => void;
|
||||||
|
onToggleRead: (id: string, currentStatus: boolean) => void;
|
||||||
|
onToggleFavorite: (id: string, currentStatus: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArticleCard({ article, onClick, onToggleRead, onToggleFavorite }: ArticleCardProps) {
|
||||||
|
const publishedDate = article.published_at
|
||||||
|
? new Date(article.published_at).toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`article-card ${article.is_read ? 'read' : 'unread'}`}
|
||||||
|
onClick={() => onClick(article)}
|
||||||
|
>
|
||||||
|
<div className="article-header">
|
||||||
|
<span className="feed-title">{article.feed_title}</span>
|
||||||
|
<span className="published-date">{publishedDate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="article-title">{article.title}</h3>
|
||||||
|
|
||||||
|
{article.summary && (
|
||||||
|
<p className="article-excerpt">
|
||||||
|
{article.summary.replace(/<[^>]*>/g, '').substring(0, 150)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="article-footer" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className={`action-btn ${article.is_favorite ? 'active' : ''}`}
|
||||||
|
onClick={() => onToggleFavorite(article.id, article.is_favorite)}
|
||||||
|
title={article.is_favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
|
||||||
|
>
|
||||||
|
{article.is_favorite ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
onClick={() => onToggleRead(article.id, article.is_read)}
|
||||||
|
title={article.is_read ? 'Marquer comme non lu' : 'Marquer comme lu'}
|
||||||
|
>
|
||||||
|
{article.is_read ? '○' : '●'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Article } from '../api/articles';
|
||||||
|
|
||||||
|
interface ReaderViewProps {
|
||||||
|
article: Article;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleFavorite: (id: string, currentStatus: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReaderView({ article, onClose, onToggleFavorite }: ReaderViewProps) {
|
||||||
|
const publishedDate = article.published_at
|
||||||
|
? new Date(article.published_at).toLocaleString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reader-overlay" onClick={onClose}>
|
||||||
|
<div className="reader-container" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<header className="reader-header">
|
||||||
|
<button className="close-btn" onClick={onClose}>✕ Fermer</button>
|
||||||
|
<div className="reader-actions">
|
||||||
|
<button
|
||||||
|
className={`action-btn ${article.is_favorite ? 'active' : ''}`}
|
||||||
|
onClick={() => onToggleFavorite(article.id, article.is_favorite)}
|
||||||
|
>
|
||||||
|
{article.is_favorite ? '★ Favori' : '☆ Favori'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={article.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="action-btn"
|
||||||
|
>
|
||||||
|
🌐 Source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article className="reader-content">
|
||||||
|
<div className="meta">
|
||||||
|
<span className="feed-name">{article.feed_title}</span>
|
||||||
|
<span className="date">{publishedDate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="title">{article.title}</h1>
|
||||||
|
|
||||||
|
{article.author && <p className="author">Par {article.author}</p>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="content-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: article.content || article.summary || '' }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { feedsApi } from '../api/feeds';
|
||||||
|
import type { Feed } from '../api/feeds';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
onSelectFeed: (feedId: string | null) => void;
|
||||||
|
selectedFeedId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ onSelectFeed, selectedFeedId }: SidebarProps) {
|
||||||
|
const { data: feeds, isLoading } = useQuery({
|
||||||
|
queryKey: ['feeds'],
|
||||||
|
queryFn: feedsApi.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<button
|
||||||
|
className={`nav-item ${selectedFeedId === null ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelectFeed(null)}
|
||||||
|
>
|
||||||
|
<span className="icon">📥</span>
|
||||||
|
<span className="label">Tous les flux</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`nav-item ${selectedFeedId === 'favorites' ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelectFeed('favorites')}
|
||||||
|
>
|
||||||
|
<span className="icon">★</span>
|
||||||
|
<span className="label">Favoris</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<h3>Flux</h3>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="sidebar-loading">Chargement...</div>
|
||||||
|
) : (
|
||||||
|
<div className="feed-list">
|
||||||
|
{feeds?.map((feed: Feed) => (
|
||||||
|
<button
|
||||||
|
key={feed.id}
|
||||||
|
className={`nav-item ${selectedFeedId === feed.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelectFeed(feed.id)}
|
||||||
|
>
|
||||||
|
<span className="icon">📄</span>
|
||||||
|
<span className="label">{feed.title}</span>
|
||||||
|
{feed.unread_count ? (
|
||||||
|
<span className="badge">{feed.unread_count}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button className="add-feed-btn" onClick={() => {/* TODO: Add feed modal */ }}>
|
||||||
|
+ Ajouter un flux
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export function useWebsocket() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const wsUrl = `${protocol}//${host}/api/v1/ws`;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
const socket = new WebSocket(wsUrl);
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('WS Message:', data);
|
||||||
|
|
||||||
|
if (data.type === 'new_articles') {
|
||||||
|
// Invalidate articles and feeds query to trigger refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse WS message', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
console.log('WS connection closed, retrying in 5s...');
|
||||||
|
setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (err) => {
|
||||||
|
console.error('WS error:', err);
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [queryClient]);
|
||||||
|
}
|
||||||
+128
-20
@@ -1,38 +1,146 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
|
import { articlesApi } from '../api/articles';
|
||||||
|
import type { Article } from '../api/articles';
|
||||||
|
import { Sidebar } from '../components/Sidebar';
|
||||||
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
|
import { ReaderView } from '../components/ReaderView';
|
||||||
|
import { useWebsocket } from '../hooks/useWebsocket';
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
// Initialize WebSocket for real-time updates
|
||||||
|
useWebsocket();
|
||||||
|
|
||||||
|
const [selectedFeedId, setSelectedFeedId] = useState<string | null>(null);
|
||||||
|
const [viewingArticle, setViewingArticle] = useState<Article | null>(null);
|
||||||
|
|
||||||
|
const { data: articles, isLoading } = useQuery({
|
||||||
|
queryKey: ['articles', selectedFeedId],
|
||||||
|
queryFn: () => articlesApi.list({
|
||||||
|
unread: true,
|
||||||
|
feed_id: (selectedFeedId && selectedFeedId !== 'favorites') ? selectedFeedId : undefined,
|
||||||
|
favorite: selectedFeedId === 'favorites' ? true : undefined
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleReadMutation = useMutation({
|
||||||
|
mutationFn: ({ id, is_read }: { id: string, is_read: boolean }) =>
|
||||||
|
is_read ? articlesApi.markUnread(id) : articlesApi.markRead(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFavoriteMutation = useMutation({
|
||||||
|
mutationFn: ({ id }: { id: string }) => articlesApi.toggleFavorite(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const markAllReadMutation = useMutation({
|
||||||
|
mutationFn: () => selectedFeedId && selectedFeedId !== 'favorites'
|
||||||
|
? articlesApi.markAllRead(selectedFeedId)
|
||||||
|
: articlesApi.markAllReadGlobal(),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await authApi.logout();
|
await authApi.logout();
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleArticleClick = async (article: Article) => {
|
||||||
<div className="dashboard">
|
try {
|
||||||
<header className="dashboard-header">
|
// Fetch full content and sanitize
|
||||||
<h1>FlowReader</h1>
|
const fullArticle = await articlesApi.get(article.id);
|
||||||
<div className="user-info">
|
setViewingArticle(fullArticle);
|
||||||
<span>{user?.email}</span>
|
|
||||||
<button onClick={handleLogout} className="logout-btn">
|
|
||||||
Déconnexion
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="dashboard-main">
|
// Mark as read automatically
|
||||||
<div className="empty-state">
|
if (!article.is_read) {
|
||||||
<h2>🎉 Bienvenue !</h2>
|
toggleReadMutation.mutate({ id: article.id, is_read: false });
|
||||||
<p>Votre lecteur RSS est prêt.</p>
|
}
|
||||||
<p className="hint">
|
} catch (err) {
|
||||||
Les fonctionnalités de flux seront disponibles dans l'Epic 2.
|
console.error('Failed to load article', err);
|
||||||
</p>
|
}
|
||||||
</div>
|
};
|
||||||
</main>
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-layout">
|
||||||
|
<Sidebar
|
||||||
|
onSelectFeed={setSelectedFeedId}
|
||||||
|
selectedFeedId={selectedFeedId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="dashboard-content">
|
||||||
|
<header className="dashboard-header">
|
||||||
|
<h1>{selectedFeedId === 'favorites' ? 'Favoris' : 'Nouveautés'}</h1>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
{articles && articles.length > 0 && selectedFeedId !== 'favorites' && (
|
||||||
|
<button
|
||||||
|
className="mark-all-read-btn"
|
||||||
|
onClick={() => markAllReadMutation.mutate()}
|
||||||
|
disabled={markAllReadMutation.isPending}
|
||||||
|
>
|
||||||
|
{markAllReadMutation.isPending ? 'Chargement...' : 'Tout marquer comme lu'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="user-info">
|
||||||
|
<span>{user?.email}</span>
|
||||||
|
<button onClick={handleLogout} className="logout-btn">
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="articles-main">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading-state">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
</div>
|
||||||
|
) : articles?.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h2>🎉 Tout est lu !</h2>
|
||||||
|
<p>Revenez plus tard pour de nouveaux articles.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="articles-grid">
|
||||||
|
{articles?.map((article: Article) => (
|
||||||
|
<ArticleCard
|
||||||
|
key={article.id}
|
||||||
|
article={article}
|
||||||
|
onClick={handleArticleClick}
|
||||||
|
onToggleRead={(id, is_read) => toggleReadMutation.mutate({ id, is_read })}
|
||||||
|
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewingArticle && (
|
||||||
|
<ReaderView
|
||||||
|
article={viewingArticle}
|
||||||
|
onClose={() => setViewingArticle(null)}
|
||||||
|
onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user