Add feed editing support with inline category updatesUnverified
81092e8 parent: 2cc88ae modified
internal/atproto/client.go +24 -0 | @@ -44,6 +44,30 @@ func (c *Client) CreateRecord(ctx context.Context, did, collection string, recor | ||
| 44 | 44 | return out.URI, out.CID, nil |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | +func (c *Client) PutRecord(ctx context.Context, did, collection, rkey string, record any) (string, string, error) { | |
| 48 | + input := map[string]any{ | |
| 49 | + "repo": did, | |
| 50 | + "collection": collection, | |
| 51 | + "rkey": rkey, | |
| 52 | + "record": record, | |
| 53 | + } | |
| 54 | + | |
| 55 | + var out struct { | |
| 56 | + URI string `json:"uri"` | |
| 57 | + CID string `json:"cid"` | |
| 58 | + } | |
| 59 | + | |
| 60 | + nsid, err := syntax.ParseNSID("com.atproto.repo.putRecord") | |
| 61 | + if err != nil { | |
| 62 | + return "", "", fmt.Errorf("parsing NSID: %w", err) | |
| 63 | + } | |
| 64 | + | |
| 65 | + if err := c.api.Post(ctx, nsid, input, &out); err != nil { | |
| 66 | + return "", "", err | |
| 67 | + } | |
| 68 | + return out.URI, out.CID, nil | |
| 69 | +} | |
| 70 | + | |
| 47 | 71 | func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error { |
| 48 | 72 | input := map[string]any{ |
| 49 | 73 | "repo": did, |
| @@ -44,6 +44,30 @@ func (c *Client) CreateRecord(ctx context.Context, did, collection string, recor | |||
| 44 | return out.URI, out.CID, nil | 44 | return out.URI, out.CID, nil |
| 45 | } | 45 | } |
| 46 | 46 | ||
| 47 | +func (c *Client) PutRecord(ctx context.Context, did, collection, rkey string, record any) (string, string, error) { | ||
| 48 | + input := map[string]any{ | ||
| 49 | + "repo": did, | ||
| 50 | + "collection": collection, | ||
| 51 | + "rkey": rkey, | ||
| 52 | + "record": record, | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + var out struct { | ||
| 56 | + URI string `json:"uri"` | ||
| 57 | + CID string `json:"cid"` | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + nsid, err := syntax.ParseNSID("com.atproto.repo.putRecord") | ||
| 61 | + if err != nil { | ||
| 62 | + return "", "", fmt.Errorf("parsing NSID: %w", err) | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + if err := c.api.Post(ctx, nsid, input, &out); err != nil { | ||
| 66 | + return "", "", err | ||
| 67 | + } | ||
| 68 | + return out.URI, out.CID, nil | ||
| 69 | +} | ||
| 70 | + | ||
| 47 | func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error { | 71 | func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error { |
| 48 | input := map[string]any{ | 72 | input := map[string]any{ |
| 49 | "repo": did, | 73 | "repo": did, |
modified
internal/atproto/sync.go +1 -1 | @@ -287,7 +287,7 @@ func (s *Sync) backfillMissingPDSRecords(ctx context.Context, userDID string) er | ||
| 287 | 287 | s.logger.Error("failed to backfill PDS record", "error", err, "url", sub.FeedURL) |
| 288 | 288 | continue |
| 289 | 289 | } |
| 290 | - if err := s.articles.UpdateSubscriptionURI(ctx, userDID, sub.FeedURL, uri, cid); err != nil { | |
| 290 | + if err := s.articles.UpdateSubscription(ctx, userDID, sub.FeedURL, sub.Title, sub.Category, uri, cid); err != nil { | |
| 291 | 291 | s.logger.Error("failed to backfill subscription URI", "error", err, "url", sub.FeedURL) |
| 292 | 292 | continue |
| 293 | 293 | } |
| @@ -287,7 +287,7 @@ func (s *Sync) backfillMissingPDSRecords(ctx context.Context, userDID string) er | |||
| 287 | s.logger.Error("failed to backfill PDS record", "error", err, "url", sub.FeedURL) | 287 | s.logger.Error("failed to backfill PDS record", "error", err, "url", sub.FeedURL) |
| 288 | continue | 288 | continue |
| 289 | } | 289 | } |
| 290 | - if err := s.articles.UpdateSubscriptionURI(ctx, userDID, sub.FeedURL, uri, cid); err != nil { | 290 | + if err := s.articles.UpdateSubscription(ctx, userDID, sub.FeedURL, sub.Title, sub.Category, uri, cid); err != nil { |
| 291 | s.logger.Error("failed to backfill subscription URI", "error", err, "url", sub.FeedURL) | 291 | s.logger.Error("failed to backfill subscription URI", "error", err, "url", sub.FeedURL) |
| 292 | continue | 292 | continue |
| 293 | } | 293 | } |
modified
internal/db/batch_test.go +7 -6 | @@ -434,7 +434,7 @@ func TestListSubscriptionsWithoutURI_EmptyWhenAllHaveURI(t *testing.T) { | ||
| 434 | 434 | assert.Equal(t, len(subs), 0) |
| 435 | 435 | } |
| 436 | 436 | |
| 437 | -func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) { | |
| 437 | +func TestUpdateSubscription_UpdatesSubscription(t *testing.T) { | |
| 438 | 438 | ctx := context.Background() |
| 439 | 439 | dbs := setupTestDB(t) |
| 440 | 440 | userDID := seedSubscriptionData(t, ctx, dbs) |
| @@ -442,7 +442,7 @@ func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) { | ||
| 442 | 442 | _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) |
| 443 | 443 | assert.NilError(t, dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "cat", "", "")) |
| 444 | 444 | |
| 445 | - assert.NilError(t, dbs.Articles.UpdateSubscriptionURI(ctx, userDID, "https://a.com/feed.xml", "at://new-uri", "new-cid")) | |
| 445 | + assert.NilError(t, dbs.Articles.UpdateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "cat", "at://new-uri", "new-cid")) | |
| 446 | 446 | |
| 447 | 447 | s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml") |
| 448 | 448 | assert.NilError(t, err) |
| @@ -452,19 +452,20 @@ func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) { | ||
| 452 | 452 | assert.Equal(t, s.Category.String, "cat") |
| 453 | 453 | } |
| 454 | 454 | |
| 455 | -func TestUpdateSubscriptionURI_NoOverwriteIfExists(t *testing.T) { | |
| 455 | +func TestUpdateSubscription_UpdatesCategory(t *testing.T) { | |
| 456 | 456 | ctx := context.Background() |
| 457 | 457 | dbs := setupTestDB(t) |
| 458 | 458 | userDID := seedSubscriptionData(t, ctx, dbs) |
| 459 | 459 | |
| 460 | 460 | _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) |
| 461 | - assert.NilError(t, dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "", "at://original", "cid1")) | |
| 461 | + assert.NilError(t, dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "old", "at://uri", "cid1")) | |
| 462 | 462 | |
| 463 | - assert.NilError(t, dbs.Articles.UpdateSubscriptionURI(ctx, userDID, "https://a.com/feed.xml", "at://new-uri", "cid2")) | |
| 463 | + assert.NilError(t, dbs.Articles.UpdateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "new", "at://uri", "cid2")) | |
| 464 | 464 | |
| 465 | 465 | s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml") |
| 466 | 466 | assert.NilError(t, err) |
| 467 | - assert.Equal(t, s.URI.String, "at://new-uri") | |
| 467 | + assert.Equal(t, s.Category.String, "new") | |
| 468 | + assert.Equal(t, s.URI.String, "at://uri") | |
| 468 | 469 | assert.Equal(t, s.CID.String, "cid2") |
| 469 | 470 | } |
| 470 | 471 | |
| @@ -434,7 +434,7 @@ func TestListSubscriptionsWithoutURI_EmptyWhenAllHaveURI(t *testing.T) { | |||
| 434 | assert.Equal(t, len(subs), 0) | 434 | assert.Equal(t, len(subs), 0) |
| 435 | } | 435 | } |
| 436 | 436 | ||
| 437 | -func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) { | 437 | +func TestUpdateSubscription_UpdatesSubscription(t *testing.T) { |
| 438 | ctx := context.Background() | 438 | ctx := context.Background() |
| 439 | dbs := setupTestDB(t) | 439 | dbs := setupTestDB(t) |
| 440 | userDID := seedSubscriptionData(t, ctx, dbs) | 440 | userDID := seedSubscriptionData(t, ctx, dbs) |
| @@ -442,7 +442,7 @@ func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) { | |||
| 442 | _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) | 442 | _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) |
| 443 | assert.NilError(t, dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "cat", "", "")) | 443 | assert.NilError(t, dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "cat", "", "")) |
| 444 | 444 | ||
| 445 | - assert.NilError(t, dbs.Articles.UpdateSubscriptionURI(ctx, userDID, "https://a.com/feed.xml", "at://new-uri", "new-cid")) | 445 | + assert.NilError(t, dbs.Articles.UpdateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "cat", "at://new-uri", "new-cid")) |
| 446 | 446 | ||
| 447 | s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml") | 447 | s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml") |
| 448 | assert.NilError(t, err) | 448 | assert.NilError(t, err) |
| @@ -452,19 +452,20 @@ func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) { | |||
| 452 | assert.Equal(t, s.Category.String, "cat") | 452 | assert.Equal(t, s.Category.String, "cat") |
| 453 | } | 453 | } |
| 454 | 454 | ||
| 455 | -func TestUpdateSubscriptionURI_NoOverwriteIfExists(t *testing.T) { | 455 | +func TestUpdateSubscription_UpdatesCategory(t *testing.T) { |
| 456 | ctx := context.Background() | 456 | ctx := context.Background() |
| 457 | dbs := setupTestDB(t) | 457 | dbs := setupTestDB(t) |
| 458 | userDID := seedSubscriptionData(t, ctx, dbs) | 458 | userDID := seedSubscriptionData(t, ctx, dbs) |
| 459 | 459 | ||
| 460 | _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) | 460 | _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) |
| 461 | - assert.NilError(t, dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "", "at://original", "cid1")) | 461 | + assert.NilError(t, dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "old", "at://uri", "cid1")) |
| 462 | 462 | ||
| 463 | - assert.NilError(t, dbs.Articles.UpdateSubscriptionURI(ctx, userDID, "https://a.com/feed.xml", "at://new-uri", "cid2")) | 463 | + assert.NilError(t, dbs.Articles.UpdateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "new", "at://uri", "cid2")) |
| 464 | 464 | ||
| 465 | s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml") | 465 | s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml") |
| 466 | assert.NilError(t, err) | 466 | assert.NilError(t, err) |
| 467 | - assert.Equal(t, s.URI.String, "at://new-uri") | 467 | + assert.Equal(t, s.Category.String, "new") |
| 468 | + assert.Equal(t, s.URI.String, "at://uri") | ||
| 468 | assert.Equal(t, s.CID.String, "cid2") | 469 | assert.Equal(t, s.CID.String, "cid2") |
| 469 | } | 470 | } |
| 470 | 471 | ||
modified
internal/db/feed.go +6 -4 | @@ -196,7 +196,9 @@ func (s *ArticleStore) ListSubscriptions(ctx context.Context, userDID, category | ||
| 196 | 196 | WHERE s.user_did = ?` |
| 197 | 197 | args := []any{userDID} |
| 198 | 198 | |
| 199 | - if category != "" { | |
| 199 | + if category == "__none__" { | |
| 200 | + query += ` AND (s.category IS NULL OR s.category = '')` | |
| 201 | + } else if category != "" { | |
| 200 | 202 | query += ` AND s.category = ?` |
| 201 | 203 | args = append(args, category) |
| 202 | 204 | } |
| @@ -434,10 +436,10 @@ func (s *ArticleStore) ListSubscriptionsWithoutURI(ctx context.Context, userDID | ||
| 434 | 436 | return subs, rows.Err() |
| 435 | 437 | } |
| 436 | 438 | |
| 437 | -func (s *ArticleStore) UpdateSubscriptionURI(ctx context.Context, userDID, feedURL, uri, cid string) error { | |
| 439 | +func (s *ArticleStore) UpdateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error { | |
| 438 | 440 | _, err := s.db.ExecContext(ctx, |
| 439 | - `UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?`, | |
| 440 | - nilIfEmpty(uri), nilIfEmpty(cid), userDID, feedURL) | |
| 441 | + `UPDATE articles.subscriptions SET title = ?, category = ?, uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?`, | |
| 442 | + nilIfEmpty(title), nilIfEmpty(category), nilIfEmpty(uri), nilIfEmpty(cid), userDID, feedURL) | |
| 441 | 443 | return err |
| 442 | 444 | } |
| 443 | 445 | |
| @@ -196,7 +196,9 @@ func (s *ArticleStore) ListSubscriptions(ctx context.Context, userDID, category | |||
| 196 | WHERE s.user_did = ?` | 196 | WHERE s.user_did = ?` |
| 197 | args := []any{userDID} | 197 | args := []any{userDID} |
| 198 | 198 | ||
| 199 | - if category != "" { | 199 | + if category == "__none__" { |
| 200 | + query += ` AND (s.category IS NULL OR s.category = '')` | ||
| 201 | + } else if category != "" { | ||
| 200 | query += ` AND s.category = ?` | 202 | query += ` AND s.category = ?` |
| 201 | args = append(args, category) | 203 | args = append(args, category) |
| 202 | } | 204 | } |
| @@ -434,10 +436,10 @@ func (s *ArticleStore) ListSubscriptionsWithoutURI(ctx context.Context, userDID | |||
| 434 | return subs, rows.Err() | 436 | return subs, rows.Err() |
| 435 | } | 437 | } |
| 436 | 438 | ||
| 437 | -func (s *ArticleStore) UpdateSubscriptionURI(ctx context.Context, userDID, feedURL, uri, cid string) error { | 439 | +func (s *ArticleStore) UpdateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error { |
| 438 | _, err := s.db.ExecContext(ctx, | 440 | _, err := s.db.ExecContext(ctx, |
| 439 | - `UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?`, | 441 | + `UPDATE articles.subscriptions SET title = ?, category = ?, uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?`, |
| 440 | - nilIfEmpty(uri), nilIfEmpty(cid), userDID, feedURL) | 442 | + nilIfEmpty(title), nilIfEmpty(category), nilIfEmpty(uri), nilIfEmpty(cid), userDID, feedURL) |
| 441 | return err | 443 | return err |
| 442 | } | 444 | } |
| 443 | 445 | ||
modified
internal/server/feeds_handler.go +65 -0 | @@ -256,6 +256,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 256 | 256 | } |
| 257 | 257 | s.render(w, r, "feed-item.html", map[string]any{ |
| 258 | 258 | "User": user, |
| 259 | + "ID": sub.ID, | |
| 259 | 260 | "FeedURL": sub.FeedURL, |
| 260 | 261 | "FeedTitle": sub.FeedTitle, |
| 261 | 262 | "Category": sub.Category, |
| @@ -264,6 +265,70 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 264 | 265 | }) |
| 265 | 266 | } |
| 266 | 267 | |
| 268 | +func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) { | |
| 269 | + user := currentUser(r) | |
| 270 | + feedURL := r.FormValue("feed_url") | |
| 271 | + category := r.FormValue("category") | |
| 272 | + | |
| 273 | + if feedURL == "" { | |
| 274 | + http.Error(w, "url required", http.StatusBadRequest) | |
| 275 | + return | |
| 276 | + } | |
| 277 | + | |
| 278 | + sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL) | |
| 279 | + if err != nil { | |
| 280 | + s.logger.Error("failed to get subscription", "error", err) | |
| 281 | + http.Error(w, "subscription not found", http.StatusNotFound) | |
| 282 | + return | |
| 283 | + } | |
| 284 | + | |
| 285 | + cid := sub.CID.String | |
| 286 | + | |
| 287 | + if sub.URI.Valid && sub.URI.String != "" { | |
| 288 | + if client := s.pdsClientForUser(r); client != nil { | |
| 289 | + parsed, ok := atproto.ParseRecordURI(sub.URI.String) | |
| 290 | + if ok { | |
| 291 | + record := atproto.SubscriptionRecord{ | |
| 292 | + CreatedAt: time.Now().Format(time.RFC3339), | |
| 293 | + FeedURL: feedURL, | |
| 294 | + Title: sub.FeedTitle, | |
| 295 | + Category: category, | |
| 296 | + } | |
| 297 | + _, newCID, putErr := client.PutRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey, record) | |
| 298 | + if putErr != nil { | |
| 299 | + s.logger.Error("failed to put subscription record on PDS", "error", putErr) | |
| 300 | + http.Error(w, "failed to update subscription on PDS: "+putErr.Error(), http.StatusInternalServerError) | |
| 301 | + return | |
| 302 | + } | |
| 303 | + cid = newCID | |
| 304 | + } | |
| 305 | + } | |
| 306 | + } | |
| 307 | + | |
| 308 | + if err := s.dbs.Articles.UpdateSubscription(r.Context(), user.DID, feedURL, sub.FeedTitle, category, sub.URI.String, cid); err != nil { | |
| 309 | + s.logger.Error("failed to update subscription", "error", err) | |
| 310 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 311 | + return | |
| 312 | + } | |
| 313 | + | |
| 314 | + updated, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL) | |
| 315 | + if err != nil { | |
| 316 | + s.logger.Warn("failed to get updated subscription", "error", err) | |
| 317 | + w.WriteHeader(http.StatusNoContent) | |
| 318 | + return | |
| 319 | + } | |
| 320 | + | |
| 321 | + s.render(w, r, "feed-item.html", map[string]any{ | |
| 322 | + "User": user, | |
| 323 | + "ID": updated.ID, | |
| 324 | + "FeedURL": updated.FeedURL, | |
| 325 | + "FeedTitle": updated.FeedTitle, | |
| 326 | + "Category": updated.Category, | |
| 327 | + "FaviconURL": updated.FaviconURL, | |
| 328 | + "UnreadCount": updated.UnreadCount, | |
| 329 | + }) | |
| 330 | +} | |
| 331 | + | |
| 267 | 332 | func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { |
| 268 | 333 | user := currentUser(r) |
| 269 | 334 | feedURL := r.FormValue("url") |
| @@ -256,6 +256,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | |||
| 256 | } | 256 | } |
| 257 | s.render(w, r, "feed-item.html", map[string]any{ | 257 | s.render(w, r, "feed-item.html", map[string]any{ |
| 258 | "User": user, | 258 | "User": user, |
| 259 | + "ID": sub.ID, | ||
| 259 | "FeedURL": sub.FeedURL, | 260 | "FeedURL": sub.FeedURL, |
| 260 | "FeedTitle": sub.FeedTitle, | 261 | "FeedTitle": sub.FeedTitle, |
| 261 | "Category": sub.Category, | 262 | "Category": sub.Category, |
| @@ -264,6 +265,70 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | |||
| 264 | }) | 265 | }) |
| 265 | } | 266 | } |
| 266 | 267 | ||
| 268 | +func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) { | ||
| 269 | + user := currentUser(r) | ||
| 270 | + feedURL := r.FormValue("feed_url") | ||
| 271 | + category := r.FormValue("category") | ||
| 272 | + | ||
| 273 | + if feedURL == "" { | ||
| 274 | + http.Error(w, "url required", http.StatusBadRequest) | ||
| 275 | + return | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL) | ||
| 279 | + if err != nil { | ||
| 280 | + s.logger.Error("failed to get subscription", "error", err) | ||
| 281 | + http.Error(w, "subscription not found", http.StatusNotFound) | ||
| 282 | + return | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + cid := sub.CID.String | ||
| 286 | + | ||
| 287 | + if sub.URI.Valid && sub.URI.String != "" { | ||
| 288 | + if client := s.pdsClientForUser(r); client != nil { | ||
| 289 | + parsed, ok := atproto.ParseRecordURI(sub.URI.String) | ||
| 290 | + if ok { | ||
| 291 | + record := atproto.SubscriptionRecord{ | ||
| 292 | + CreatedAt: time.Now().Format(time.RFC3339), | ||
| 293 | + FeedURL: feedURL, | ||
| 294 | + Title: sub.FeedTitle, | ||
| 295 | + Category: category, | ||
| 296 | + } | ||
| 297 | + _, newCID, putErr := client.PutRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey, record) | ||
| 298 | + if putErr != nil { | ||
| 299 | + s.logger.Error("failed to put subscription record on PDS", "error", putErr) | ||
| 300 | + http.Error(w, "failed to update subscription on PDS: "+putErr.Error(), http.StatusInternalServerError) | ||
| 301 | + return | ||
| 302 | + } | ||
| 303 | + cid = newCID | ||
| 304 | + } | ||
| 305 | + } | ||
| 306 | + } | ||
| 307 | + | ||
| 308 | + if err := s.dbs.Articles.UpdateSubscription(r.Context(), user.DID, feedURL, sub.FeedTitle, category, sub.URI.String, cid); err != nil { | ||
| 309 | + s.logger.Error("failed to update subscription", "error", err) | ||
| 310 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 311 | + return | ||
| 312 | + } | ||
| 313 | + | ||
| 314 | + updated, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL) | ||
| 315 | + if err != nil { | ||
| 316 | + s.logger.Warn("failed to get updated subscription", "error", err) | ||
| 317 | + w.WriteHeader(http.StatusNoContent) | ||
| 318 | + return | ||
| 319 | + } | ||
| 320 | + | ||
| 321 | + s.render(w, r, "feed-item.html", map[string]any{ | ||
| 322 | + "User": user, | ||
| 323 | + "ID": updated.ID, | ||
| 324 | + "FeedURL": updated.FeedURL, | ||
| 325 | + "FeedTitle": updated.FeedTitle, | ||
| 326 | + "Category": updated.Category, | ||
| 327 | + "FaviconURL": updated.FaviconURL, | ||
| 328 | + "UnreadCount": updated.UnreadCount, | ||
| 329 | + }) | ||
| 330 | +} | ||
| 331 | + | ||
| 267 | func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { | 332 | func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { |
| 268 | user := currentUser(r) | 333 | user := currentUser(r) |
| 269 | feedURL := r.FormValue("url") | 334 | feedURL := r.FormValue("url") |
modified
internal/server/server.go +1 -0 | @@ -162,6 +162,7 @@ func (s *Server) setupRoutes() { | ||
| 162 | 162 | r.Use(s.requireAuth) |
| 163 | 163 | r.Get("/", s.handleFeeds) |
| 164 | 164 | r.Post("/add", s.handleAddFeed) |
| 165 | + r.Post("/edit", s.handleEditFeed) | |
| 165 | 166 | r.Delete("/remove", s.handleRemoveFeed) |
| 166 | 167 | r.Post("/opml/upload", s.handleOPMLUpload) |
| 167 | 168 | r.Get("/opml/download", s.handleOPMLDownload) |
| @@ -162,6 +162,7 @@ func (s *Server) setupRoutes() { | |||
| 162 | r.Use(s.requireAuth) | 162 | r.Use(s.requireAuth) |
| 163 | r.Get("/", s.handleFeeds) | 163 | r.Get("/", s.handleFeeds) |
| 164 | r.Post("/add", s.handleAddFeed) | 164 | r.Post("/add", s.handleAddFeed) |
| 165 | + r.Post("/edit", s.handleEditFeed) | ||
| 165 | r.Delete("/remove", s.handleRemoveFeed) | 166 | r.Delete("/remove", s.handleRemoveFeed) |
| 166 | r.Post("/opml/upload", s.handleOPMLUpload) | 167 | r.Post("/opml/upload", s.handleOPMLUpload) |
| 167 | r.Get("/opml/download", s.handleOPMLDownload) | 168 | r.Get("/opml/download", s.handleOPMLDownload) |
modified
internal/tmpl/feeds.html +4 -2 | @@ -15,6 +15,7 @@ | ||
| 15 | 15 | <script> |
| 16 | 16 | function gleanRefreshStart(){document.getElementById('refresh-indicator').style.display='inline';document.getElementById('refresh-btn').disabled=true} |
| 17 | 17 | function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list',{target:'#feed-list',swap:'innerHTML'});document.getElementById('refresh-indicator').style.display='none';document.getElementById('refresh-btn').disabled=false},5000)} |
| 18 | +function gleanToggleEdit(id){var i=document.getElementById('feed-edit-'+id);if(i){i.classList.toggle('hidden');if(!i.classList.contains('hidden'))i.querySelector('input[name=category]').focus()}} | |
| 18 | 19 | </script> |
| 19 | 20 | <p class="text-sm text-spot-secondary mb-6">Manage your RSS, Atom, and AT Protocol subscriptions.</p> |
| 20 | 21 | |
| @@ -33,18 +34,19 @@ function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list', | ||
| 33 | 34 | |
| 34 | 35 | <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
| 35 | 36 | <div class="lg:col-span-2"> |
| 36 | - {{if .Categories}} | |
| 37 | + {{if .SubscriptionCount}} | |
| 37 | 38 | <div class="flex gap-1.5 mb-6 flex-wrap"> |
| 38 | 39 | <a href="/feeds" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a> |
| 39 | 40 | {{range .Categories}} |
| 40 | 41 | <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a> |
| 41 | 42 | {{end}} |
| 43 | + <a href="/feeds?category=__none__" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq .Category "__none__"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">Uncategorized</a> | |
| 42 | 44 | </div> |
| 43 | 45 | {{end}} |
| 44 | 46 | |
| 45 | 47 | <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider overflow-hidden"> |
| 46 | 48 | {{range .Subscriptions}} |
| 47 | - {{template "feed-item.html" dict "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | |
| 49 | + {{template "feed-item.html" dict "ID" .ID "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | |
| 48 | 50 | {{else}} |
| 49 | 51 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> |
| 50 | 52 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-4 mx-auto"> |
| @@ -15,6 +15,7 @@ | |||
| 15 | <script> | 15 | <script> |
| 16 | function gleanRefreshStart(){document.getElementById('refresh-indicator').style.display='inline';document.getElementById('refresh-btn').disabled=true} | 16 | function gleanRefreshStart(){document.getElementById('refresh-indicator').style.display='inline';document.getElementById('refresh-btn').disabled=true} |
| 17 | function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list',{target:'#feed-list',swap:'innerHTML'});document.getElementById('refresh-indicator').style.display='none';document.getElementById('refresh-btn').disabled=false},5000)} | 17 | function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list',{target:'#feed-list',swap:'innerHTML'});document.getElementById('refresh-indicator').style.display='none';document.getElementById('refresh-btn').disabled=false},5000)} |
| 18 | +function gleanToggleEdit(id){var i=document.getElementById('feed-edit-'+id);if(i){i.classList.toggle('hidden');if(!i.classList.contains('hidden'))i.querySelector('input[name=category]').focus()}} | ||
| 18 | </script> | 19 | </script> |
| 19 | <p class="text-sm text-spot-secondary mb-6">Manage your RSS, Atom, and AT Protocol subscriptions.</p> | 20 | <p class="text-sm text-spot-secondary mb-6">Manage your RSS, Atom, and AT Protocol subscriptions.</p> |
| 20 | 21 | ||
| @@ -33,18 +34,19 @@ function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list', | |||
| 33 | 34 | ||
| 34 | <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | 35 | <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
| 35 | <div class="lg:col-span-2"> | 36 | <div class="lg:col-span-2"> |
| 36 | - {{if .Categories}} | 37 | + {{if .SubscriptionCount}} |
| 37 | <div class="flex gap-1.5 mb-6 flex-wrap"> | 38 | <div class="flex gap-1.5 mb-6 flex-wrap"> |
| 38 | <a href="/feeds" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a> | 39 | <a href="/feeds" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a> |
| 39 | {{range .Categories}} | 40 | {{range .Categories}} |
| 40 | <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a> | 41 | <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a> |
| 41 | {{end}} | 42 | {{end}} |
| 43 | + <a href="/feeds?category=__none__" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq .Category "__none__"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">Uncategorized</a> | ||
| 42 | </div> | 44 | </div> |
| 43 | {{end}} | 45 | {{end}} |
| 44 | 46 | ||
| 45 | <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider overflow-hidden"> | 47 | <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider overflow-hidden"> |
| 46 | {{range .Subscriptions}} | 48 | {{range .Subscriptions}} |
| 47 | - {{template "feed-item.html" dict "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | 49 | + {{template "feed-item.html" dict "ID" .ID "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} |
| 48 | {{else}} | 50 | {{else}} |
| 49 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> | 51 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> |
| 50 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-4 mx-auto"> | 52 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-4 mx-auto"> |
modified
internal/tmpl/partials/feed-item.html +26 -4 | @@ -1,4 +1,5 @@ | ||
| 1 | 1 | {{define "feed-item.html"}} |
| 2 | +<div id="feed-{{.ID}}" class="contents"> | |
| 2 | 3 | <div class="feed-item px-5 py-4 flex items-center justify-between gap-3 hover:bg-spot-hover-50 transition"> |
| 3 | 4 | <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3"> |
| 4 | 5 | {{template "favicon" dict "src" .FaviconURL.String "size" "w-5 h-5"}} |
| @@ -10,14 +11,35 @@ | ||
| 10 | 11 | <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> |
| 11 | 12 | </div> |
| 12 | 13 | </a> |
| 13 | - <div class="flex items-center gap-3 shrink-0"> | |
| 14 | + <div class="flex items-center gap-1.5 shrink-0"> | |
| 14 | 15 | {{if .UnreadCount}}<span class="text-xs bg-spot-green/15 text-spot-green px-2.5 py-0.5 rounded-pill font-bold">{{.UnreadCount}}</span>{{end}} |
| 15 | - <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s" | |
| 16 | - hx-confirm="Unsubscribe from this feed?" class="inline"> | |
| 16 | + <button type="button" | |
| 17 | + onclick="gleanToggleEdit({{.ID}})" | |
| 18 | + title="Edit Category" | |
| 19 | + class="text-spot-muted hover:text-spot-text transition p-1 rounded hover:bg-spot-hover inline-flex items-center"> | |
| 20 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg> | |
| 21 | + </button> | |
| 22 | + <form hx-delete="/feeds/remove" hx-target="#feed-{{.ID}}" hx-swap="outerHTML swap:0.3s" | |
| 23 | + hx-confirm="Unsubscribe from this feed?" class="inline-flex items-center"> | |
| 17 | 24 | {{csrfInput .CSRFToken}} |
| 18 | 25 | <input type="hidden" name="url" value="{{.FeedURL}}"> |
| 19 | - <button type="submit" class="text-xs text-spot-secondary hover:text-spot-red transition font-medium">Unsubscribe</button> | |
| 26 | + <button type="submit" title="Unsubscribe" class="text-spot-muted hover:text-spot-red transition p-1 rounded hover:bg-spot-red/10 inline-flex items-center"> | |
| 27 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg> | |
| 28 | + </button> | |
| 20 | 29 | </form> |
| 21 | 30 | </div> |
| 22 | 31 | </div> |
| 32 | +<div id="feed-edit-{{.ID}}" class="px-5 py-4 bg-spot-hover/50 hidden"> | |
| 33 | + <form hx-post="/feeds/edit" hx-target="#feed-{{.ID}}" hx-swap="outerHTML" | |
| 34 | + class="flex items-center gap-2"> | |
| 35 | + {{csrfInput .CSRFToken}} | |
| 36 | + <input type="hidden" name="feed_url" value="{{.FeedURL}}"> | |
| 37 | + <input type="text" name="category" value="{{if .Category.Valid}}{{.Category.String}}{{end}}" | |
| 38 | + placeholder="Category (optional)" | |
| 39 | + class="flex-1 bg-spot-surface text-spot-text rounded-pill px-4 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"> | |
| 40 | + <button type="submit" class="bg-spot-green text-white rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button> | |
| 41 | + <button type="button" onclick="gleanToggleEdit({{.ID}})" class="text-xs text-spot-secondary hover:text-spot-text transition font-medium px-2 py-1.5">Cancel</button> | |
| 42 | + </form> | |
| 43 | +</div> | |
| 44 | +</div> | |
| 23 | 45 | {{end}} |
| @@ -1,4 +1,5 @@ | |||
| 1 | {{define "feed-item.html"}} | 1 | {{define "feed-item.html"}} |
| 2 | +<div id="feed-{{.ID}}" class="contents"> | ||
| 2 | <div class="feed-item px-5 py-4 flex items-center justify-between gap-3 hover:bg-spot-hover-50 transition"> | 3 | <div class="feed-item px-5 py-4 flex items-center justify-between gap-3 hover:bg-spot-hover-50 transition"> |
| 3 | <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3"> | 4 | <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3"> |
| 4 | {{template "favicon" dict "src" .FaviconURL.String "size" "w-5 h-5"}} | 5 | {{template "favicon" dict "src" .FaviconURL.String "size" "w-5 h-5"}} |
| @@ -10,14 +11,35 @@ | |||
| 10 | <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> | 11 | <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> |
| 11 | </div> | 12 | </div> |
| 12 | </a> | 13 | </a> |
| 13 | - <div class="flex items-center gap-3 shrink-0"> | 14 | + <div class="flex items-center gap-1.5 shrink-0"> |
| 14 | {{if .UnreadCount}}<span class="text-xs bg-spot-green/15 text-spot-green px-2.5 py-0.5 rounded-pill font-bold">{{.UnreadCount}}</span>{{end}} | 15 | {{if .UnreadCount}}<span class="text-xs bg-spot-green/15 text-spot-green px-2.5 py-0.5 rounded-pill font-bold">{{.UnreadCount}}</span>{{end}} |
| 15 | - <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s" | 16 | + <button type="button" |
| 16 | - hx-confirm="Unsubscribe from this feed?" class="inline"> | 17 | + onclick="gleanToggleEdit({{.ID}})" |
| 18 | + title="Edit Category" | ||
| 19 | + class="text-spot-muted hover:text-spot-text transition p-1 rounded hover:bg-spot-hover inline-flex items-center"> | ||
| 20 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg> | ||
| 21 | + </button> | ||
| 22 | + <form hx-delete="/feeds/remove" hx-target="#feed-{{.ID}}" hx-swap="outerHTML swap:0.3s" | ||
| 23 | + hx-confirm="Unsubscribe from this feed?" class="inline-flex items-center"> | ||
| 17 | {{csrfInput .CSRFToken}} | 24 | {{csrfInput .CSRFToken}} |
| 18 | <input type="hidden" name="url" value="{{.FeedURL}}"> | 25 | <input type="hidden" name="url" value="{{.FeedURL}}"> |
| 19 | - <button type="submit" class="text-xs text-spot-secondary hover:text-spot-red transition font-medium">Unsubscribe</button> | 26 | + <button type="submit" title="Unsubscribe" class="text-spot-muted hover:text-spot-red transition p-1 rounded hover:bg-spot-red/10 inline-flex items-center"> |
| 27 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg> | ||
| 28 | + </button> | ||
| 20 | </form> | 29 | </form> |
| 21 | </div> | 30 | </div> |
| 22 | </div> | 31 | </div> |
| 32 | +<div id="feed-edit-{{.ID}}" class="px-5 py-4 bg-spot-hover/50 hidden"> | ||
| 33 | + <form hx-post="/feeds/edit" hx-target="#feed-{{.ID}}" hx-swap="outerHTML" | ||
| 34 | + class="flex items-center gap-2"> | ||
| 35 | + {{csrfInput .CSRFToken}} | ||
| 36 | + <input type="hidden" name="feed_url" value="{{.FeedURL}}"> | ||
| 37 | + <input type="text" name="category" value="{{if .Category.Valid}}{{.Category.String}}{{end}}" | ||
| 38 | + placeholder="Category (optional)" | ||
| 39 | + class="flex-1 bg-spot-surface text-spot-text rounded-pill px-4 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"> | ||
| 40 | + <button type="submit" class="bg-spot-green text-white rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button> | ||
| 41 | + <button type="button" onclick="gleanToggleEdit({{.ID}})" class="text-xs text-spot-secondary hover:text-spot-text transition font-medium px-2 py-1.5">Cancel</button> | ||
| 42 | + </form> | ||
| 43 | +</div> | ||
| 44 | +</div> | ||
| 23 | {{end}} | 45 | {{end}} |
modified
internal/tmpl/partials/feed-list.html +1 -1 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {{define "feed-list.html"}} |
| 2 | 2 | {{range .Subscriptions}} |
| 3 | -{{template "feed-item.html" dict "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | |
| 3 | +{{template "feed-item.html" dict "ID" .ID "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | |
| 4 | 4 | {{else}} |
| 5 | 5 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> |
| 6 | 6 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> |
| @@ -1,6 +1,6 @@ | |||
| 1 | {{define "feed-list.html"}} | 1 | {{define "feed-list.html"}} |
| 2 | {{range .Subscriptions}} | 2 | {{range .Subscriptions}} |
| 3 | -{{template "feed-item.html" dict "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} | 3 | +{{template "feed-item.html" dict "ID" .ID "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}} |
| 4 | {{else}} | 4 | {{else}} |
| 5 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> | 5 | <div class="flex flex-col items-center justify-center py-12 px-6 text-center"> |
| 6 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> | 6 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> |