diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 99e399d..20f3efd 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -44,17 +44,17 @@ development_status: epic-1-retrospective: optional # Epic 2: Feed Core Engine - epic-2: in-progress + epic-2: done 2-1-feed-model-add-feed-by-url: done 2-2-feed-metadata-auto-discovery: done 2-3-opml-import: done 2-4-feed-management-crud: done - 2-5-background-feed-fetcher: review + 2-5-background-feed-fetcher: done epic-2-retrospective: optional # Epic 3: Lecture Plaisir & Real-Time Experience - epic-3: backlog - 3-1-dashboard-nouveautes: backlog + epic-3: in-progress + 3-1-dashboard-nouveautes: in-progress 3-2-article-detail-reader-mode: backlog 3-3-mark-as-read-manual-auto: backlog 3-4-mark-all-as-read: backlog diff --git a/cmd/server/main.go b/cmd/server/main.go index cfbeee4..80cd593 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "github.com/michael/flowreader/internal/repository" "github.com/michael/flowreader/internal/service" "github.com/michael/flowreader/internal/worker" + "github.com/michael/flowreader/internal/ws" ) func main() { @@ -45,12 +46,18 @@ func main() { // Initialize services authService := service.NewAuthService(userRepo, sessionRepo) 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 authHandler := handler.NewAuthHandler(authService) feedHandler := handler.NewFeedHandler(feedService, authService) articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService) + wsHandler := handler.NewWSHandler(hub, authService) // Start background feed fetcher fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5) @@ -112,12 +119,16 @@ func main() { // Article routes r.Route("/articles", func(r chi.Router) { r.Get("/", articleHandler.List) + 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) }) + + // WebSocket route + r.Get("/ws", wsHandler.Connect) }) // Create server diff --git a/go.mod b/go.mod index 867b851..6c299af 100644 --- a/go.mod +++ b/go.mod @@ -10,17 +10,21 @@ require ( require ( github.com/PuerkitoBio/goquery v1.8.0 // 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/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/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // 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/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - golang.org/x/crypto v0.17.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/text v0.16.0 // indirect ) diff --git a/internal/domain/article.go b/internal/domain/article.go index 19109aa..df4466b 100644 --- a/internal/domain/article.go +++ b/internal/domain/article.go @@ -38,6 +38,7 @@ type ArticleRepository interface { MarkAsRead(id uuid.UUID) error MarkAsUnread(id uuid.UUID) error MarkAllAsRead(feedID uuid.UUID) error + MarkAllAsReadGlobal(userID uuid.UUID) error ToggleFavorite(id uuid.UUID) error GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error) CountUnread(feedID uuid.UUID) (int, error) diff --git a/internal/handler/article.go b/internal/handler/article.go index b22ac64..048e266 100644 --- a/internal/handler/article.go +++ b/internal/handler/article.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/michael/flowreader/internal/domain" "github.com/michael/flowreader/internal/service" + "github.com/michael/flowreader/internal/utils" ) // ArticleHandler handles article-related HTTP requests. @@ -15,6 +16,7 @@ type ArticleHandler struct { articleRepo domain.ArticleRepository feedService *service.FeedService authService *service.AuthService + sanitizer *utils.ContentSanitizer } // NewArticleHandler creates a new article handler. @@ -23,6 +25,7 @@ func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *servic articleRepo: articleRepo, feedService: feedService, authService: authService, + sanitizer: utils.NewContentSanitizer(), } } @@ -139,6 +142,13 @@ func (h *ArticleHandler) Get(w http.ResponseWriter, r *http.Request) { 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) } @@ -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"}) } +// 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 func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) { userID, err := h.getUserFromRequest(r) diff --git a/internal/handler/ws.go b/internal/handler/ws.go new file mode 100644 index 0000000..211abef --- /dev/null +++ b/internal/handler/ws.go @@ -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) +} diff --git a/internal/repository/article.go b/internal/repository/article.go index 6caebde..d055d76 100644 --- a/internal/repository/article.go +++ b/internal/repository/article.go @@ -235,6 +235,24 @@ func (r *ArticleRepository) MarkAllAsRead(feedID uuid.UUID) error { 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. func (r *ArticleRepository) ToggleFavorite(id uuid.UUID) error { ctx := context.Background() diff --git a/internal/service/fetch.go b/internal/service/fetch.go index 513bb92..89cbcd0 100644 --- a/internal/service/fetch.go +++ b/internal/service/fetch.go @@ -9,21 +9,24 @@ import ( "github.com/google/uuid" "github.com/michael/flowreader/internal/domain" "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 { feedRepo domain.FeedRepository articleRepo domain.ArticleRepository parser *parser.FeedParser + hub *ws.Hub } // 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{ feedRepo: feedRepo, articleRepo: articleRepo, parser: parser.NewFeedParser(), + hub: hub, } } @@ -38,7 +41,7 @@ func (s *FetchService) FetchFeed(ctx context.Context, feedID uuid.UUID) error { } // 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 { // Update feed with 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 - feed.Title = parsed.Title - feed.Description = parsed.Description - feed.SiteURL = parsed.SiteURL - feed.ImageURL = parsed.ImageURL + feed.Title = parsedFeed.Title + feed.Description = parsedFeed.Description + feed.SiteURL = parsedFeed.SiteURL + feed.ImageURL = parsedFeed.ImageURL if err := s.feedRepo.Update(feed); err != nil { log.Printf("Warning: failed to update feed metadata: %v", err) } - // Insert new articles (ON CONFLICT DO NOTHING handles duplicates) - if len(parsed.Articles) > 0 { - if err := s.articleRepo.CreateBatch(parsed.Articles); err != nil { - return fmt.Errorf("creating articles: %w", err) + // Ingest articles + if len(parsedFeed.Articles) > 0 { + if err := s.articleRepo.CreateBatch(parsedFeed.Articles); err != nil { + 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), + }) } } diff --git a/internal/utils/sanitizer.go b/internal/utils/sanitizer.go new file mode 100644 index 0000000..fa96529 --- /dev/null +++ b/internal/utils/sanitizer.go @@ -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) +} diff --git a/internal/ws/hub.go b/internal/ws/hub.go new file mode 100644 index 0000000..f3811ad --- /dev/null +++ b/internal/ws/hub.go @@ -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 + } + } + } +} diff --git a/web/src/App.css b/web/src/App.css index df71e41..14ac96f 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -146,29 +146,144 @@ button[type="submit"]:disabled { text-decoration: underline; } -/* Dashboard */ -.dashboard { - min-height: 100vh; +/* Dashboard Layout */ +.dashboard-layout { + display: flex; + height: 100vh; + overflow: hidden; +} + +.sidebar { + width: 280px; + background: var(--surface); + border-right: 1px solid var(--border); display: flex; 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 { - background: var(--surface); padding: 1rem 1.5rem; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); + background: var(--surface); } -.dashboard-header h1 { - font-size: 1.25rem; - font-weight: 600; - background: linear-gradient(135deg, var(--primary) 0%, #a855f7 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; +.header-actions { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.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 { @@ -177,52 +292,227 @@ button[type="submit"]:disabled { gap: 1rem; } -.user-info span { - color: var(--text-muted); - font-size: 0.875rem; +.articles-main { + flex: 1; + 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; border: 1px solid var(--border); color: var(--text-muted); - padding: 0.5rem 1rem; + padding: 0.375rem 0.75rem; border-radius: 6px; cursor: pointer; - font-size: 0.875rem; transition: all 0.2s ease; + font-size: 1rem; } -.logout-btn:hover { +.action-btn:hover { background: var(--surface-hover); color: var(--text); } -.dashboard-main { - flex: 1; +.action-btn.active { + 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; align-items: center; - justify-content: center; - padding: 2rem; + gap: 0.5rem; } -.empty-state { - text-align: center; +.reader-actions { + display: flex; + gap: 1rem; } -.empty-state h2 { - font-size: 2rem; - margin-bottom: 0.5rem; +.reader-content { + flex: 1; + overflow-y: auto; + padding: 3rem 4rem; + line-height: 1.6; } -.empty-state p { - color: var(--text-muted); - margin-bottom: 0.25rem; -} - -.empty-state .hint { +.reader-content .meta { + display: flex; + gap: 1rem; 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; + 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 */ @@ -255,8 +545,8 @@ button[type="submit"]:disabled { .login-card { padding: 1.5rem; } - + .login-card h1 { font-size: 1.5rem; } -} +} \ No newline at end of file diff --git a/web/src/api/articles.ts b/web/src/api/articles.ts new file mode 100644 index 0000000..a0968e0 --- /dev/null +++ b/web/src/api/articles.ts @@ -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 { + 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(response); + }, + + async get(id: string): Promise
{ + const response = await fetch(`${API_BASE}/articles/${id}`, { + credentials: 'include', + }); + return handleResponse
(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); + }, +}; diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 7e16696..18fe1c6 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,48 +1,33 @@ -const API_BASE = '/api/v1'; +import { API_BASE, handleResponse } from './client'; -interface RegisterRequest { - email: string; - password: string; -} - -interface LoginRequest { - email: string; - password: string; -} - -interface User { +export interface User { id: string; email: string; is_admin: boolean; } -interface LoginResponse { +export interface RegisterRequest { + email: string; + password: string; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { token: string; expires_at: string; user: User; } -interface RegisterResponse { +export interface RegisterResponse { id: string; email: string; created_at: string; } -class ApiError extends Error { - constructor(public status: number, message: string) { - super(message); - this.name = 'ApiError'; - } -} - -async function handleResponse(response: Response): Promise { - 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 = { async register(data: RegisterRequest): Promise { const response = await fetch(`${API_BASE}/auth/register`, { @@ -78,6 +63,3 @@ export const authApi = { return handleResponse(response); }, }; - -export { ApiError }; -export type { User, LoginResponse, RegisterResponse }; diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..dd2da23 --- /dev/null +++ b/web/src/api/client.ts @@ -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(response: Response): Promise { + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Request failed' })); + throw new ApiError(response.status, error.error || 'Request failed'); + } + return response.json(); +} diff --git a/web/src/api/feeds.ts b/web/src/api/feeds.ts new file mode 100644 index 0000000..0f80804 --- /dev/null +++ b/web/src/api/feeds.ts @@ -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 { + const response = await fetch(`${API_BASE}/feeds`, { + credentials: 'include', + }); + return handleResponse(response); + }, + + async add(data: AddFeedRequest): Promise { + const response = await fetch(`${API_BASE}/feeds`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(data), + }); + return handleResponse(response); + }, + + async get(id: string): Promise { + const response = await fetch(`${API_BASE}/feeds/${id}`, { + credentials: 'include', + }); + return handleResponse(response); + }, + + async delete(id: string): Promise { + 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); + }, +}; diff --git a/web/src/components/ArticleCard.tsx b/web/src/components/ArticleCard.tsx new file mode 100644 index 0000000..873c4f4 --- /dev/null +++ b/web/src/components/ArticleCard.tsx @@ -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 ( +
onClick(article)} + > +
+ {article.feed_title} + {publishedDate} +
+ +

{article.title}

+ + {article.summary && ( +

+ {article.summary.replace(/<[^>]*>/g, '').substring(0, 150)}... +

+ )} + +
e.stopPropagation()}> + + +
+
+ ); +} diff --git a/web/src/components/ReaderView.tsx b/web/src/components/ReaderView.tsx new file mode 100644 index 0000000..3aa3eaf --- /dev/null +++ b/web/src/components/ReaderView.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+ +
+ + + 🌐 Source + +
+
+ +
+
+ {article.feed_title} + {publishedDate} +
+ +

{article.title}

+ + {article.author &&

Par {article.author}

} + +
+
+
+
+ ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..818de9d --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/web/src/hooks/useWebsocket.ts b/web/src/hooks/useWebsocket.ts new file mode 100644 index 0000000..03385a6 --- /dev/null +++ b/web/src/hooks/useWebsocket.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +export function useWebsocket() { + const queryClient = useQueryClient(); + const socketRef = useRef(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]); +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 7ced0e7..757a096 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -1,38 +1,146 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../stores/authStore'; 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() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const { user, logout } = useAuthStore(); + // Initialize WebSocket for real-time updates + useWebsocket(); + + const [selectedFeedId, setSelectedFeedId] = useState(null); + const [viewingArticle, setViewingArticle] = useState
(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 () => { await authApi.logout(); logout(); navigate('/login'); }; - return ( -
-
-

FlowReader

-
- {user?.email} - -
-
+ const handleArticleClick = async (article: Article) => { + try { + // Fetch full content and sanitize + const fullArticle = await articlesApi.get(article.id); + setViewingArticle(fullArticle); -
-
-

🎉 Bienvenue !

-

Votre lecteur RSS est prêt.

-

- Les fonctionnalités de flux seront disponibles dans l'Epic 2. -

-
-
+ // Mark as read automatically + if (!article.is_read) { + toggleReadMutation.mutate({ id: article.id, is_read: false }); + } + } catch (err) { + console.error('Failed to load article', err); + } + }; + + return ( +
+ + +
+
+

{selectedFeedId === 'favorites' ? 'Favoris' : 'Nouveautés'}

+ +
+ {articles && articles.length > 0 && selectedFeedId !== 'favorites' && ( + + )} + +
+ {user?.email} + +
+
+
+ +
+ {isLoading ? ( +
+
+
+ ) : articles?.length === 0 ? ( +
+

🎉 Tout est lu !

+

Revenez plus tard pour de nouveaux articles.

+
+ ) : ( +
+ {articles?.map((article: Article) => ( + toggleReadMutation.mutate({ id, is_read })} + onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })} + /> + ))} +
+ )} +
+
+ + {viewingArticle && ( + setViewingArticle(null)} + onToggleFavorite={(id) => toggleFavoriteMutation.mutate({ id })} + /> + )}
); }