nandi/gleanpublic Fork 0
81092e8
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add feed editing support with inline category updatesUnverified

Julien Robert committed 2026-05-11T00:05:23+02:00 Browse files
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
4444 return out.URI, out.CID, nil
4545 }
4646
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+
4771 func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error {
4872 input := map[string]any{
4973 "repo": did,
@@ -44,6 +44,30 @@ func (c *Client) CreateRecord(ctx context.Context, did, collection string, recor
44 return out.URI, out.CID, nil44 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
287287 s.logger.Error("failed to backfill PDS record", "error", err, "url", sub.FeedURL)
288288 continue
289289 }
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 {
291291 s.logger.Error("failed to backfill subscription URI", "error", err, "url", sub.FeedURL)
292292 continue
293293 }
@@ -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 continue288 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 continue292 continue
293 }293 }
modified internal/db/batch_test.go +7 -6
@@ -434,7 +434,7 @@ func TestListSubscriptionsWithoutURI_EmptyWhenAllHaveURI(t *testing.T) {
434434 assert.Equal(t, len(subs), 0)
435435 }
436436
437-func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) {
437+func TestUpdateSubscription_UpdatesSubscription(t *testing.T) {
438438 ctx := context.Background()
439439 dbs := setupTestDB(t)
440440 userDID := seedSubscriptionData(t, ctx, dbs)
@@ -442,7 +442,7 @@ func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) {
442442 _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"})
443443 assert.NilError(t, dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "A", "cat", "", ""))
444444
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"))
446446
447447 s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml")
448448 assert.NilError(t, err)
@@ -452,19 +452,20 @@ func TestUpdateSubscriptionURI_UpdatesSubscription(t *testing.T) {
452452 assert.Equal(t, s.Category.String, "cat")
453453 }
454454
455-func TestUpdateSubscriptionURI_NoOverwriteIfExists(t *testing.T) {
455+func TestUpdateSubscription_UpdatesCategory(t *testing.T) {
456456 ctx := context.Background()
457457 dbs := setupTestDB(t)
458458 userDID := seedSubscriptionData(t, ctx, dbs)
459459
460460 _ = 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"))
462462
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"))
464464
465465 s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml")
466466 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")
468469 assert.Equal(t, s.CID.String, "cid2")
469470 }
470471
@@ -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
196196 WHERE s.user_did = ?`
197197 args := []any{userDID}
198198
199- if category != "" {
199+ if category == "__none__" {
200+ query += ` AND (s.category IS NULL OR s.category = '')`
201+ } else if category != "" {
200202 query += ` AND s.category = ?`
201203 args = append(args, category)
202204 }
@@ -434,10 +436,10 @@ func (s *ArticleStore) ListSubscriptionsWithoutURI(ctx context.Context, userDID
434436 return subs, rows.Err()
435437 }
436438
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 {
438440 _, 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)
441443 return err
442444 }
443445
@@ -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 err443 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) {
256256 }
257257 s.render(w, r, "feed-item.html", map[string]any{
258258 "User": user,
259+ "ID": sub.ID,
259260 "FeedURL": sub.FeedURL,
260261 "FeedTitle": sub.FeedTitle,
261262 "Category": sub.Category,
@@ -264,6 +265,70 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
264265 })
265266 }
266267
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+
267332 func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
268333 user := currentUser(r)
269334 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() {
162162 r.Use(s.requireAuth)
163163 r.Get("/", s.handleFeeds)
164164 r.Post("/add", s.handleAddFeed)
165+ r.Post("/edit", s.handleEditFeed)
165166 r.Delete("/remove", s.handleRemoveFeed)
166167 r.Post("/opml/upload", s.handleOPMLUpload)
167168 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 @@
1515 <script>
1616 function gleanRefreshStart(){document.getElementById('refresh-indicator').style.display='inline';document.getElementById('refresh-btn').disabled=true}
1717 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()}}
1819 </script>
1920 <p class="text-sm text-spot-secondary mb-6">Manage your RSS, Atom, and AT Protocol subscriptions.</p>
2021
@@ -33,18 +34,19 @@ function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list',
3334
3435 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
3536 <div class="lg:col-span-2">
36- {{if .Categories}}
37+ {{if .SubscriptionCount}}
3738 <div class="flex gap-1.5 mb-6 flex-wrap">
3839 <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>
3940 {{range .Categories}}
4041 <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>
4142 {{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>
4244 </div>
4345 {{end}}
4446
4547 <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider overflow-hidden">
4648 {{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}}
4850 {{else}}
4951 <div class="flex flex-col items-center justify-center py-12 px-6 text-center">
5052 <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 @@
11 {{define "feed-item.html"}}
2+<div id="feed-{{.ID}}" class="contents">
23 <div class="feed-item px-5 py-4 flex items-center justify-between gap-3 hover:bg-spot-hover-50 transition">
34 <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3">
45 {{template "favicon" dict "src" .FaviconURL.String "size" "w-5 h-5"}}
@@ -10,14 +11,35 @@
1011 <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div>
1112 </div>
1213 </a>
13- <div class="flex items-center gap-3 shrink-0">
14+ <div class="flex items-center gap-1.5 shrink-0">
1415 {{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">
1724 {{csrfInput .CSRFToken}}
1825 <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>
2029 </form>
2130 </div>
2231 </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>
2345 {{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 @@
11 {{define "feed-list.html"}}
22 {{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}}
44 {{else}}
55 <div class="flex flex-col items-center justify-center py-12 px-6 text-center">
66 <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">