modified internal/db/article.go +12 -0
| @@ -3,6 +3,7 @@ package db |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | + "time" |
| 6 | 7 | ) |
| 7 | 8 | |
| 8 | 9 | type Article struct { |
| @@ -281,3 +282,14 @@ func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error) |
| 281 | 282 | } |
| 282 | 283 | return a, nil |
| 283 | 284 | } |
| 285 | + |
| 286 | +func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.Time) (int, error) { |
| 287 | + var count int |
| 288 | + err := db.QueryRowContext(ctx, ` |
| 289 | + SELECT COUNT(*) |
| 290 | + FROM articles a |
| 291 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? |
| 292 | + WHERE a.fetched_at > ? |
| 293 | + `, userDID, since).Scan(&count) |
| 294 | + return count, err |
| 295 | +} |
| @@ -3,6 +3,7 @@ package db |
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "database/sql" | 5 | "database/sql" |
| | 6 | + "time" |
| 6 | ) | 7 | ) |
| 7 | | 8 | |
| 8 | type Article struct { | 9 | type Article struct { |
| @@ -281,3 +282,14 @@ func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error) |
| 281 | } | 282 | } |
| 282 | return a, nil | 283 | return a, nil |
| 283 | } | 284 | } |
| | 285 | + |
| | 286 | +func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.Time) (int, error) { |
| | 287 | + var count int |
| | 288 | + err := db.QueryRowContext(ctx, ` |
| | 289 | + SELECT COUNT(*) |
| | 290 | + FROM articles a |
| | 291 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? |
| | 292 | + WHERE a.fetched_at > ? |
| | 293 | + `, userDID, since).Scan(&count) |
| | 294 | + return count, err |
| | 295 | +} |
modified internal/server/articles_handler.go +31 -0
| @@ -65,9 +65,40 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { |
| 65 | 65 | "Page": page, |
| 66 | 66 | "BaseURL": "/articles", |
| 67 | 67 | "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}), |
| 68 | + "Now": time.Now(), |
| 68 | 69 | }) |
| 69 | 70 | } |
| 70 | 71 | |
| 72 | +func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) { |
| 73 | + user := currentUser(r) |
| 74 | + sinceUnix, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) |
| 75 | + if err != nil { |
| 76 | + w.WriteHeader(http.StatusBadRequest) |
| 77 | + return |
| 78 | + } |
| 79 | + since := time.Unix(sinceUnix, 0) |
| 80 | + |
| 81 | + count, err := s.db.CountNewArticles(r.Context(), user.DID, since) |
| 82 | + if err != nil { |
| 83 | + w.WriteHeader(http.StatusInternalServerError) |
| 84 | + return |
| 85 | + } |
| 86 | + |
| 87 | + w.Header().Set("Content-Type", "text/html") |
| 88 | + if count == 0 { |
| 89 | + w.Write([]byte("")) |
| 90 | + return |
| 91 | + } |
| 92 | + fmt.Fprintf(w, `<div id="new-articles-banner" class="bg-spot-green rounded-xl px-5 py-3 flex items-center justify-between mb-4"><span class="text-sm text-white font-medium">%d new article%s available.</span><a href="%s" class="text-sm font-bold text-white uppercase tracking-button hover:underline transition">Refresh</a></div>`, count, pluralS(count), r.URL.Query().Get("return")) |
| 93 | +} |
| 94 | + |
| 95 | +func pluralS(n int) string { |
| 96 | + if n != 1 { |
| 97 | + return "s" |
| 98 | + } |
| 99 | + return "" |
| 100 | +} |
| 101 | + |
| 71 | 102 | func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { |
| 72 | 103 | user := currentUser(r) |
| 73 | 104 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| @@ -65,9 +65,40 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { |
| 65 | "Page": page, | 65 | "Page": page, |
| 66 | "BaseURL": "/articles", | 66 | "BaseURL": "/articles", |
| 67 | "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}), | 67 | "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}), |
| | 68 | + "Now": time.Now(), |
| 68 | }) | 69 | }) |
| 69 | } | 70 | } |
| 70 | | 71 | |
| | 72 | +func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) { |
| | 73 | + user := currentUser(r) |
| | 74 | + sinceUnix, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) |
| | 75 | + if err != nil { |
| | 76 | + w.WriteHeader(http.StatusBadRequest) |
| | 77 | + return |
| | 78 | + } |
| | 79 | + since := time.Unix(sinceUnix, 0) |
| | 80 | + |
| | 81 | + count, err := s.db.CountNewArticles(r.Context(), user.DID, since) |
| | 82 | + if err != nil { |
| | 83 | + w.WriteHeader(http.StatusInternalServerError) |
| | 84 | + return |
| | 85 | + } |
| | 86 | + |
| | 87 | + w.Header().Set("Content-Type", "text/html") |
| | 88 | + if count == 0 { |
| | 89 | + w.Write([]byte("")) |
| | 90 | + return |
| | 91 | + } |
| | 92 | + fmt.Fprintf(w, `<div id="new-articles-banner" class="bg-spot-green rounded-xl px-5 py-3 flex items-center justify-between mb-4"><span class="text-sm text-white font-medium">%d new article%s available.</span><a href="%s" class="text-sm font-bold text-white uppercase tracking-button hover:underline transition">Refresh</a></div>`, count, pluralS(count), r.URL.Query().Get("return")) |
| | 93 | +} |
| | 94 | + |
| | 95 | +func pluralS(n int) string { |
| | 96 | + if n != 1 { |
| | 97 | + return "s" |
| | 98 | + } |
| | 99 | + return "" |
| | 100 | +} |
| | 101 | + |
| 71 | func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | 102 | func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { |
| 72 | user := currentUser(r) | 103 | user := currentUser(r) |
| 73 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | 104 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
modified internal/server/dashboard_handler.go +1 -0
| @@ -38,5 +38,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { |
| 38 | 38 | "Page": page, |
| 39 | 39 | "BaseURL": "/dashboard", |
| 40 | 40 | "QueryParams": map[string]string{}, |
| 41 | + "Now": time.Now(), |
| 41 | 42 | }) |
| 42 | 43 | } |
| @@ -38,5 +38,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { |
| 38 | "Page": page, | 38 | "Page": page, |
| 39 | "BaseURL": "/dashboard", | 39 | "BaseURL": "/dashboard", |
| 40 | "QueryParams": map[string]string{}, | 40 | "QueryParams": map[string]string{}, |
| | 41 | + "Now": time.Now(), |
| 41 | }) | 42 | }) |
| 42 | } | 43 | } |
modified internal/server/server.go +1 -0
| @@ -163,6 +163,7 @@ func (s *Server) setupRoutes() { |
| 163 | 163 | s.router.Route("/articles", func(r chi.Router) { |
| 164 | 164 | r.Use(s.requireAuth) |
| 165 | 165 | r.Get("/", s.handleArticles) |
| 166 | + r.Get("/new-count", s.handleNewArticleCount) |
| 166 | 167 | r.Get("/{id}", s.handleArticleDetail) |
| 167 | 168 | r.Post("/{id}/read", s.handleMarkRead) |
| 168 | 169 | r.Post("/{id}/unread", s.handleMarkUnread) |
| @@ -163,6 +163,7 @@ func (s *Server) setupRoutes() { |
| 163 | s.router.Route("/articles", func(r chi.Router) { | 163 | s.router.Route("/articles", func(r chi.Router) { |
| 164 | r.Use(s.requireAuth) | 164 | r.Use(s.requireAuth) |
| 165 | r.Get("/", s.handleArticles) | 165 | r.Get("/", s.handleArticles) |
| | 166 | + r.Get("/new-count", s.handleNewArticleCount) |
| 166 | r.Get("/{id}", s.handleArticleDetail) | 167 | r.Get("/{id}", s.handleArticleDetail) |
| 167 | r.Post("/{id}/read", s.handleMarkRead) | 168 | r.Post("/{id}/read", s.handleMarkRead) |
| 168 | r.Post("/{id}/unread", s.handleMarkUnread) | 169 | r.Post("/{id}/unread", s.handleMarkUnread) |