nandi/gleanpublic⑂ Fork 0
⑂ a6093b7
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 subscription title storage and clear all featureUnverified

Julien Robert committed 2026-04-20T17:13:40+02:00 Browse files
a6093b7 parent: b7c422e
modified internal/atproto/firehose_handler.go +1 -1
@@ -52,7 +52,7 @@ func (h *FirehoseDBHandler) handleSubscription(ctx context.Context, event *Fireh
5252
5353 f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}
5454 _ = h.db.UpsertFeed(ctx, f)
55- return h.db.CreateSubscription(ctx, event.DID, rec.FeedURL, rec.Category, event.URI, event.CID)
55+ return h.db.CreateSubscription(ctx, event.DID, rec.FeedURL, rec.Title, rec.Category, event.URI, event.CID)
5656
5757 case "delete":
5858 parsed, ok := ParseRecordURI(event.URI)
@@ -52,7 +52,7 @@ func (h *FirehoseDBHandler) handleSubscription(ctx context.Context, event *Fireh
52 52
53 f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}53 f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}
54 _ = h.db.UpsertFeed(ctx, f)54 _ = h.db.UpsertFeed(ctx, f)
55- return h.db.CreateSubscription(ctx, event.DID, rec.FeedURL, rec.Category, event.URI, event.CID)55+ return h.db.CreateSubscription(ctx, event.DID, rec.FeedURL, rec.Title, rec.Category, event.URI, event.CID)
56 56
57 case "delete":57 case "delete":
58 parsed, ok := ParseRecordURI(event.URI)58 parsed, ok := ParseRecordURI(event.URI)
modified internal/atproto/sync.go +1 -1
@@ -80,7 +80,7 @@ func (s *Sync) reconcileSubscription(ctx context.Context, userDID, uri, cid stri
8080 f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}
8181 _ = s.db.UpsertFeed(ctx, f)
8282
83- return s.db.CreateSubscription(ctx, userDID, rec.FeedURL, rec.Category, uri, cid)
83+ return s.db.CreateSubscription(ctx, userDID, rec.FeedURL, rec.Title, rec.Category, uri, cid)
8484 }
8585
8686 func (s *Sync) reconcileLike(ctx context.Context, userDID, uri, cid string, value json.RawMessage) error {
@@ -80,7 +80,7 @@ func (s *Sync) reconcileSubscription(ctx context.Context, userDID, uri, cid stri
80 f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}80 f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)}
81 _ = s.db.UpsertFeed(ctx, f)81 _ = s.db.UpsertFeed(ctx, f)
82 82
83- return s.db.CreateSubscription(ctx, userDID, rec.FeedURL, rec.Category, uri, cid)83+ return s.db.CreateSubscription(ctx, userDID, rec.FeedURL, rec.Title, rec.Category, uri, cid)
84 }84 }
85 85
86 func (s *Sync) reconcileLike(ctx context.Context, userDID, uri, cid string, value json.RawMessage) error {86 func (s *Sync) reconcileLike(ctx context.Context, userDID, uri, cid string, value json.RawMessage) error {
modified internal/atproto/xrpc.go +2 -2
@@ -25,7 +25,7 @@ func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request)
2525 cursor := r.URL.Query().Get("cursor")
2626
2727 query := `
28- SELECT s.id, f.feed_url, f.title, s.category, s.added_at
28+ SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at
2929 FROM subscriptions s
3030 JOIN feeds f ON s.feed_url = f.feed_url
3131 WHERE s.user_did = ?`
@@ -428,7 +428,7 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
428428 }
429429
430430 subRows, err := h.db.QueryContext(r.Context(), `
431- SELECT f.feed_url, f.title, s.category
431+ SELECT s.feed_url, COALESCE(s.title, f.title), s.category
432432 FROM subscriptions s
433433 JOIN feeds f ON s.feed_url = f.feed_url
434434 WHERE s.user_did = ?
@@ -25,7 +25,7 @@ func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request)
25 cursor := r.URL.Query().Get("cursor")25 cursor := r.URL.Query().Get("cursor")
26 26
27 query := `27 query := `
28- SELECT s.id, f.feed_url, f.title, s.category, s.added_at28+ SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at
29 FROM subscriptions s29 FROM subscriptions s
30 JOIN feeds f ON s.feed_url = f.feed_url30 JOIN feeds f ON s.feed_url = f.feed_url
31 WHERE s.user_did = ?`31 WHERE s.user_did = ?`
@@ -428,7 +428,7 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
428 }428 }
429 429
430 subRows, err := h.db.QueryContext(r.Context(), `430 subRows, err := h.db.QueryContext(r.Context(), `
431- SELECT f.feed_url, f.title, s.category431+ SELECT s.feed_url, COALESCE(s.title, f.title), s.category
432 FROM subscriptions s432 FROM subscriptions s
433 JOIN feeds f ON s.feed_url = f.feed_url433 JOIN feeds f ON s.feed_url = f.feed_url
434 WHERE s.user_did = ?434 WHERE s.user_did = ?
modified internal/db/db.go +1 -0
@@ -85,6 +85,7 @@ var schema = []string{
8585 id INTEGER PRIMARY KEY AUTOINCREMENT,
8686 user_did TEXT NOT NULL REFERENCES users(did),
8787 feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
88+ title TEXT,
8889 category TEXT,
8990 added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
9091 uri TEXT,
@@ -85,6 +85,7 @@ var schema = []string{
85 id INTEGER PRIMARY KEY AUTOINCREMENT,85 id INTEGER PRIMARY KEY AUTOINCREMENT,
86 user_did TEXT NOT NULL REFERENCES users(did),86 user_did TEXT NOT NULL REFERENCES users(did),
87 feed_url TEXT NOT NULL REFERENCES feeds(feed_url),87 feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
88+ title TEXT,
88 category TEXT,89 category TEXT,
89 added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,90 added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
90 uri TEXT,91 uri TEXT,
modified internal/db/feed.go +49 -6
@@ -126,11 +126,11 @@ func (db *DB) DecrementSubscriberCount(ctx context.Context, feedURL string) erro
126126 return err
127127 }
128128
129-func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, category, uri, cid string) error {
129+func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error {
130130 _, err := db.ExecContext(ctx, `
131- INSERT INTO subscriptions (user_did, feed_url, category, uri, cid)
132- VALUES (?, ?, ?, ?, ?)
133- `, userDID, feedURL, category, uriOrNil(category, uri), uriOrNil(category, cid))
131+ INSERT INTO subscriptions (user_did, feed_url, title, category, uri, cid)
132+ VALUES (?, ?, ?, ?, ?, ?)
133+ `, userDID, feedURL, nilIfEmpty(title), category, uriOrNil(category, uri), uriOrNil(category, cid))
134134 if err != nil {
135135 return err
136136 }
@@ -151,6 +151,13 @@ func uriOrNil(category, v string) any {
151151 return v
152152 }
153153
154+func nilIfEmpty(v string) any {
155+ if v == "" {
156+ return nil
157+ }
158+ return v
159+}
160+
154161 func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error {
155162 _, err := db.ExecContext(ctx, `
156163 DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ?
@@ -161,10 +168,46 @@ func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) e
161168 return db.DecrementSubscriberCount(ctx, feedURL)
162169 }
163170
171+func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error {
172+ tx, err := db.BeginTx(ctx, nil)
173+ if err != nil {
174+ return err
175+ }
176+ defer tx.Rollback()
177+
178+ rows, err := tx.QueryContext(ctx, `SELECT feed_url FROM subscriptions WHERE user_did = ?`, userDID)
179+ if err != nil {
180+ return err
181+ }
182+ var feedURLs []string
183+ for rows.Next() {
184+ var u string
185+ if err := rows.Scan(&u); err != nil {
186+ rows.Close()
187+ return err
188+ }
189+ feedURLs = append(feedURLs, u)
190+ }
191+ rows.Close()
192+
193+ _, err = tx.ExecContext(ctx, `DELETE FROM subscriptions WHERE user_did = ?`, userDID)
194+ if err != nil {
195+ return err
196+ }
197+
198+ for _, u := range feedURLs {
199+ if _, err := tx.ExecContext(ctx, `UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, u); err != nil {
200+ return err
201+ }
202+ }
203+
204+ return tx.Commit()
205+}
206+
164207 func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) {
165208 s := &Subscription{}
166209 err := db.QueryRowContext(ctx, `
167- SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at,
210+ SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
168211 COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid
169212 FROM subscriptions s
170213 LEFT JOIN feeds f ON s.feed_url = f.feed_url
@@ -177,7 +220,7 @@ func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Su
177220 }
178221
179222 func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) {
180- query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at,
223+ query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
181224 COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid
182225 FROM subscriptions s
183226 LEFT JOIN feeds f ON s.feed_url = f.feed_url
@@ -126,11 +126,11 @@ func (db *DB) DecrementSubscriberCount(ctx context.Context, feedURL string) erro
126 return err126 return err
127 }127 }
128 128
129-func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, category, uri, cid string) error {129+func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error {
130 _, err := db.ExecContext(ctx, `130 _, err := db.ExecContext(ctx, `
131- INSERT INTO subscriptions (user_did, feed_url, category, uri, cid)131+ INSERT INTO subscriptions (user_did, feed_url, title, category, uri, cid)
132- VALUES (?, ?, ?, ?, ?)132+ VALUES (?, ?, ?, ?, ?, ?)
133- `, userDID, feedURL, category, uriOrNil(category, uri), uriOrNil(category, cid))133+ `, userDID, feedURL, nilIfEmpty(title), category, uriOrNil(category, uri), uriOrNil(category, cid))
134 if err != nil {134 if err != nil {
135 return err135 return err
136 }136 }
@@ -151,6 +151,13 @@ func uriOrNil(category, v string) any {
151 return v151 return v
152 }152 }
153 153
154+func nilIfEmpty(v string) any {
155+ if v == "" {
156+ return nil
157+ }
158+ return v
159+}
160+
154 func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error {161 func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error {
155 _, err := db.ExecContext(ctx, `162 _, err := db.ExecContext(ctx, `
156 DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ?163 DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ?
@@ -161,10 +168,46 @@ func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) e
161 return db.DecrementSubscriberCount(ctx, feedURL)168 return db.DecrementSubscriberCount(ctx, feedURL)
162 }169 }
163 170
171+func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error {
172+ tx, err := db.BeginTx(ctx, nil)
173+ if err != nil {
174+ return err
175+ }
176+ defer tx.Rollback()
177+
178+ rows, err := tx.QueryContext(ctx, `SELECT feed_url FROM subscriptions WHERE user_did = ?`, userDID)
179+ if err != nil {
180+ return err
181+ }
182+ var feedURLs []string
183+ for rows.Next() {
184+ var u string
185+ if err := rows.Scan(&u); err != nil {
186+ rows.Close()
187+ return err
188+ }
189+ feedURLs = append(feedURLs, u)
190+ }
191+ rows.Close()
192+
193+ _, err = tx.ExecContext(ctx, `DELETE FROM subscriptions WHERE user_did = ?`, userDID)
194+ if err != nil {
195+ return err
196+ }
197+
198+ for _, u := range feedURLs {
199+ if _, err := tx.ExecContext(ctx, `UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, u); err != nil {
200+ return err
201+ }
202+ }
203+
204+ return tx.Commit()
205+}
206+
164 func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) {207 func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) {
165 s := &Subscription{}208 s := &Subscription{}
166 err := db.QueryRowContext(ctx, `209 err := db.QueryRowContext(ctx, `
167- SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at,210+ SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
168 COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid211 COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid
169 FROM subscriptions s212 FROM subscriptions s
170 LEFT JOIN feeds f ON s.feed_url = f.feed_url213 LEFT JOIN feeds f ON s.feed_url = f.feed_url
@@ -177,7 +220,7 @@ func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Su
177 }220 }
178 221
179 func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) {222 func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) {
180- query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at,223+ query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
181 COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid224 COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid
182 FROM subscriptions s225 FROM subscriptions s
183 LEFT JOIN feeds f ON s.feed_url = f.feed_url226 LEFT JOIN feeds f ON s.feed_url = f.feed_url
modified internal/feed/opml.go +61 -29
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "encoding/xml"
66 "io"
7+ "strings"
78 )
89
910 type OPML struct {
@@ -18,30 +19,60 @@ type OPML struct {
1819 }
1920
2021 type Outline struct {
21- Text string `xml:"text,attr"`
22- Title string `xml:"title,attr"`
23- XMLURL string `xml:"xmlUrl,attr"`
24- HTMLURL string `xml:"htmlUrl,attr"`
25- Outlines []Outline `xml:"outline"`
22+ Text string `xml:"text,attr"`
23+ Title string `xml:"title,attr"`
24+ XMLURL string `xml:"xmlUrl,attr"`
25+ HTMLURL string `xml:"htmlUrl,attr"`
26+ Description string `xml:"description,attr"`
27+ Outlines []Outline `xml:"outline"`
2628 }
2729
28-type FeedURL struct {
29- URL string
30- Title string
31- Category string
30+func (o Outline) GetTitle() string {
31+ if o.Title != "" {
32+ return o.Title
33+ }
34+ if o.Text != "" {
35+ return o.Text
36+ }
37+ if o.HTMLURL != "" {
38+ return o.HTMLURL
39+ }
40+ if o.XMLURL != "" {
41+ return o.XMLURL
42+ }
43+ return ""
3244 }
3345
34-func ParseOPML(r io.Reader) (*OPML, error) {
35- data, err := io.ReadAll(r)
36- if err != nil {
37- return nil, err
46+func (o Outline) GetSiteURL() string {
47+ if o.HTMLURL != "" {
48+ return o.HTMLURL
3849 }
50+ return o.XMLURL
51+}
52+
53+func (o Outline) IsSubscription() bool {
54+ return strings.TrimSpace(o.XMLURL) != ""
55+}
3956
57+func (o Outline) HasChildren() bool {
58+ return len(o.Outlines) > 0
59+}
60+
61+type FeedURL struct {
62+ URL string
63+ Title string
64+ SiteURL string
65+ Description string
66+ Category string
67+}
68+
69+func ParseOPML(r io.Reader) (*OPML, error) {
4070 var opml OPML
41- if err := xml.Unmarshal(data, &opml); err != nil {
71+ dec := xml.NewDecoder(r)
72+ dec.Strict = false
73+ if err := dec.Decode(&opml); err != nil {
4274 return nil, err
4375 }
44-
4576 return &opml, nil
4677 }
4778
@@ -53,18 +84,17 @@ func ExtractFeedURLs(opml *OPML) []FeedURL {
5384
5485 func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) {
5586 for _, o := range outlines {
56- if o.XMLURL != "" {
87+ if o.IsSubscription() {
5788 *urls = append(*urls, FeedURL{
58- URL: o.XMLURL,
59- Title: o.Title,
60- Category: category,
89+ URL: o.XMLURL,
90+ Title: o.GetTitle(),
91+ SiteURL: o.GetSiteURL(),
92+ Description: o.Description,
93+ Category: category,
6194 })
95+ } else if o.HasChildren() {
96+ extractOutlines(o.Outlines, o.GetTitle(), urls)
6297 }
63- childCategory := o.Text
64- if childCategory == "" {
65- childCategory = category
66- }
67- extractOutlines(o.Outlines, childCategory, urls)
6898 }
6999 }
70100
@@ -75,11 +105,13 @@ func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) {
75105 opml.Head.Title = title
76106
77107 for _, f := range feeds {
78- opml.Body.Outlines = append(opml.Body.Outlines, Outline{
79- Text: f.Title,
80- Title: f.Title,
81- XMLURL: f.URL,
82- })
108+ outline := Outline{
109+ Text: f.Title,
110+ Title: f.Title,
111+ XMLURL: f.URL,
112+ HTMLURL: f.SiteURL,
113+ }
114+ opml.Body.Outlines = append(opml.Body.Outlines, outline)
83115 }
84116
85117 var buf bytes.Buffer
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "encoding/xml"5 "encoding/xml"
6 "io"6 "io"
7+ "strings"
7 )8 )
8 9
9 type OPML struct {10 type OPML struct {
@@ -18,30 +19,60 @@ type OPML struct {
18 }19 }
19 20
20 type Outline struct {21 type Outline struct {
21- Text string `xml:"text,attr"`22+ Text string `xml:"text,attr"`
22- Title string `xml:"title,attr"`23+ Title string `xml:"title,attr"`
23- XMLURL string `xml:"xmlUrl,attr"`24+ XMLURL string `xml:"xmlUrl,attr"`
24- HTMLURL string `xml:"htmlUrl,attr"`25+ HTMLURL string `xml:"htmlUrl,attr"`
25- Outlines []Outline `xml:"outline"`26+ Description string `xml:"description,attr"`
27+ Outlines []Outline `xml:"outline"`
26 }28 }
27 29
28-type FeedURL struct {30+func (o Outline) GetTitle() string {
29- URL string31+ if o.Title != "" {
30- Title string32+ return o.Title
31- Category string33+ }
34+ if o.Text != "" {
35+ return o.Text
36+ }
37+ if o.HTMLURL != "" {
38+ return o.HTMLURL
39+ }
40+ if o.XMLURL != "" {
41+ return o.XMLURL
42+ }
43+ return ""
32 }44 }
33 45
34-func ParseOPML(r io.Reader) (*OPML, error) {46+func (o Outline) GetSiteURL() string {
35- data, err := io.ReadAll(r)47+ if o.HTMLURL != "" {
36- if err != nil {48+ return o.HTMLURL
37- return nil, err
38 }49 }
50+ return o.XMLURL
51+}
52+
53+func (o Outline) IsSubscription() bool {
54+ return strings.TrimSpace(o.XMLURL) != ""
55+}
39 56
57+func (o Outline) HasChildren() bool {
58+ return len(o.Outlines) > 0
59+}
60+
61+type FeedURL struct {
62+ URL string
63+ Title string
64+ SiteURL string
65+ Description string
66+ Category string
67+}
68+
69+func ParseOPML(r io.Reader) (*OPML, error) {
40 var opml OPML70 var opml OPML
41- if err := xml.Unmarshal(data, &opml); err != nil {71+ dec := xml.NewDecoder(r)
72+ dec.Strict = false
73+ if err := dec.Decode(&opml); err != nil {
42 return nil, err74 return nil, err
43 }75 }
44-
45 return &opml, nil76 return &opml, nil
46 }77 }
47 78
@@ -53,18 +84,17 @@ func ExtractFeedURLs(opml *OPML) []FeedURL {
53 84
54 func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) {85 func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) {
55 for _, o := range outlines {86 for _, o := range outlines {
56- if o.XMLURL != "" {87+ if o.IsSubscription() {
57 *urls = append(*urls, FeedURL{88 *urls = append(*urls, FeedURL{
58- URL: o.XMLURL,89+ URL: o.XMLURL,
59- Title: o.Title,90+ Title: o.GetTitle(),
60- Category: category,91+ SiteURL: o.GetSiteURL(),
92+ Description: o.Description,
93+ Category: category,
61 })94 })
95+ } else if o.HasChildren() {
96+ extractOutlines(o.Outlines, o.GetTitle(), urls)
62 }97 }
63- childCategory := o.Text
64- if childCategory == "" {
65- childCategory = category
66- }
67- extractOutlines(o.Outlines, childCategory, urls)
68 }98 }
69 }99 }
70 100
@@ -75,11 +105,13 @@ func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) {
75 opml.Head.Title = title105 opml.Head.Title = title
76 106
77 for _, f := range feeds {107 for _, f := range feeds {
78- opml.Body.Outlines = append(opml.Body.Outlines, Outline{108+ outline := Outline{
79- Text: f.Title,109+ Text: f.Title,
80- Title: f.Title,110+ Title: f.Title,
81- XMLURL: f.URL,111+ XMLURL: f.URL,
82- })112+ HTMLURL: f.SiteURL,
113+ }
114+ opml.Body.Outlines = append(opml.Body.Outlines, outline)
83 }115 }
84 116
85 var buf bytes.Buffer117 var buf bytes.Buffer
modified internal/server/feeds_handler.go +38 -17
@@ -42,6 +42,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
4242 s.render(w, r, "feeds.html", map[string]any{
4343 "User": user,
4444 "Subscriptions": subs,
45+ "SubscriptionCount": len(allSubs),
4546 "Categories": categories,
4647 "Category": category,
4748 "FeedRecommendations": feedRecs,
@@ -116,7 +117,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
116117 subCID = cid
117118 }
118119
119- if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category, subURI, subCID); err != nil {
120+ if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, feedTitle, category, subURI, subCID); err != nil {
120121 s.logger.Error("failed to create subscription", "error", err)
121122 http.Error(w, err.Error(), http.StatusInternalServerError)
122123 return
@@ -158,7 +159,34 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
158159 return
159160 }
160161
161- w.WriteHeader(http.StatusNoContent)
162+ w.WriteHeader(http.StatusOK)
163+}
164+
165+func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Request) {
166+ user := currentUser(r)
167+
168+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0)
169+ if client := s.pdsClientForUser(r); client != nil {
170+ for _, sub := range subs {
171+ if sub.URI.Valid {
172+ parsed, ok := atproto.ParseRecordURI(sub.URI.String)
173+ if ok {
174+ if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil {
175+ s.logger.Error("failed to delete subscription from PDS", "error", delErr, "uri", sub.URI.String)
176+ }
177+ }
178+ }
179+ }
180+ }
181+
182+ if err := s.db.DeleteAllSubscriptions(r.Context(), user.DID); err != nil {
183+ s.logger.Error("failed to clear subscriptions", "error", err)
184+ http.Error(w, err.Error(), http.StatusInternalServerError)
185+ return
186+ }
187+
188+ w.Header().Set("HX-Redirect", "/feeds")
189+ w.WriteHeader(http.StatusOK)
162190 }
163191
164192 func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
@@ -181,8 +209,10 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
181209 client := s.pdsClientForUser(r)
182210 for _, fu := range feedURLs {
183211 f := &db.Feed{
184- FeedURL: fu.URL,
185- Title: nullString(fu.Title),
212+ FeedURL: fu.URL,
213+ Title: nullString(fu.Title),
214+ SiteURL: nullString(fu.SiteURL),
215+ Description: nullString(fu.Description),
186216 }
187217 if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil {
188218 s.logger.Error("failed to upsert feed", "error", upsertErr)
@@ -206,19 +236,15 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
206236 subCID = cid
207237 }
208238
209- if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Category, subURI, subCID); subErr != nil {
239+ if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Title, fu.Category, subURI, subCID); subErr != nil {
210240 s.logger.Error("failed to create subscription", "error", subErr)
211241 continue
212242 }
213243 added++
214244 }
215245
216- subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)
217- s.render(w, r, "feeds.html", map[string]any{
218- "User": user,
219- "Subscriptions": subs,
220- "AddedCount": added,
221- })
246+ w.Header().Set("HX-Redirect", "/feeds")
247+ w.WriteHeader(http.StatusOK)
222248 }
223249
224250 func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
@@ -227,14 +253,9 @@ func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
227253
228254 var feedURLs []feed.FeedURL
229255 for _, sub := range subs {
230- f, err := s.db.GetFeed(r.Context(), sub.FeedURL)
231- title := ""
232- if err == nil {
233- title = f.Title.String
234- }
235256 feedURLs = append(feedURLs, feed.FeedURL{
236257 URL: sub.FeedURL,
237- Title: title,
258+ Title: sub.FeedTitle,
238259 })
239260 }
240261
@@ -42,6 +42,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
42 s.render(w, r, "feeds.html", map[string]any{42 s.render(w, r, "feeds.html", map[string]any{
43 "User": user,43 "User": user,
44 "Subscriptions": subs,44 "Subscriptions": subs,
45+ "SubscriptionCount": len(allSubs),
45 "Categories": categories,46 "Categories": categories,
46 "Category": category,47 "Category": category,
47 "FeedRecommendations": feedRecs,48 "FeedRecommendations": feedRecs,
@@ -116,7 +117,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
116 subCID = cid117 subCID = cid
117 }118 }
118 119
119- if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category, subURI, subCID); err != nil {120+ if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, feedTitle, category, subURI, subCID); err != nil {
120 s.logger.Error("failed to create subscription", "error", err)121 s.logger.Error("failed to create subscription", "error", err)
121 http.Error(w, err.Error(), http.StatusInternalServerError)122 http.Error(w, err.Error(), http.StatusInternalServerError)
122 return123 return
@@ -158,7 +159,34 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
158 return159 return
159 }160 }
160 161
161- w.WriteHeader(http.StatusNoContent)162+ w.WriteHeader(http.StatusOK)
163+}
164+
165+func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Request) {
166+ user := currentUser(r)
167+
168+ subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0)
169+ if client := s.pdsClientForUser(r); client != nil {
170+ for _, sub := range subs {
171+ if sub.URI.Valid {
172+ parsed, ok := atproto.ParseRecordURI(sub.URI.String)
173+ if ok {
174+ if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil {
175+ s.logger.Error("failed to delete subscription from PDS", "error", delErr, "uri", sub.URI.String)
176+ }
177+ }
178+ }
179+ }
180+ }
181+
182+ if err := s.db.DeleteAllSubscriptions(r.Context(), user.DID); err != nil {
183+ s.logger.Error("failed to clear subscriptions", "error", err)
184+ http.Error(w, err.Error(), http.StatusInternalServerError)
185+ return
186+ }
187+
188+ w.Header().Set("HX-Redirect", "/feeds")
189+ w.WriteHeader(http.StatusOK)
162 }190 }
163 191
164 func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {192 func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
@@ -181,8 +209,10 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
181 client := s.pdsClientForUser(r)209 client := s.pdsClientForUser(r)
182 for _, fu := range feedURLs {210 for _, fu := range feedURLs {
183 f := &db.Feed{211 f := &db.Feed{
184- FeedURL: fu.URL,212+ FeedURL: fu.URL,
185- Title: nullString(fu.Title),213+ Title: nullString(fu.Title),
214+ SiteURL: nullString(fu.SiteURL),
215+ Description: nullString(fu.Description),
186 }216 }
187 if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil {217 if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil {
188 s.logger.Error("failed to upsert feed", "error", upsertErr)218 s.logger.Error("failed to upsert feed", "error", upsertErr)
@@ -206,19 +236,15 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
206 subCID = cid236 subCID = cid
207 }237 }
208 238
209- if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Category, subURI, subCID); subErr != nil {239+ if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Title, fu.Category, subURI, subCID); subErr != nil {
210 s.logger.Error("failed to create subscription", "error", subErr)240 s.logger.Error("failed to create subscription", "error", subErr)
211 continue241 continue
212 }242 }
213 added++243 added++
214 }244 }
215 245
216- subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0)246+ w.Header().Set("HX-Redirect", "/feeds")
217- s.render(w, r, "feeds.html", map[string]any{247+ w.WriteHeader(http.StatusOK)
218- "User": user,
219- "Subscriptions": subs,
220- "AddedCount": added,
221- })
222 }248 }
223 249
224 func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {250 func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
@@ -227,14 +253,9 @@ func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
227 253
228 var feedURLs []feed.FeedURL254 var feedURLs []feed.FeedURL
229 for _, sub := range subs {255 for _, sub := range subs {
230- f, err := s.db.GetFeed(r.Context(), sub.FeedURL)
231- title := ""
232- if err == nil {
233- title = f.Title.String
234- }
235 feedURLs = append(feedURLs, feed.FeedURL{256 feedURLs = append(feedURLs, feed.FeedURL{
236 URL: sub.FeedURL,257 URL: sub.FeedURL,
237- Title: title,258+ Title: sub.FeedTitle,
238 })259 })
239 }260 }
240 261
modified internal/server/server.go +1 -0
@@ -107,6 +107,7 @@ func (s *Server) setupRoutes() {
107107 r.Get("/list", s.handleFeedList)
108108 r.Post("/set-interval", s.handleUpdateFeedInterval)
109109 r.Get("/discover-url", s.handleDiscoverFeedURL)
110+ r.Post("/clear", s.handleClearAllSubscriptions)
110111 })
111112
112113 s.router.Route("/articles", func(r chi.Router) {
@@ -107,6 +107,7 @@ func (s *Server) setupRoutes() {
107 r.Get("/list", s.handleFeedList)107 r.Get("/list", s.handleFeedList)
108 r.Post("/set-interval", s.handleUpdateFeedInterval)108 r.Post("/set-interval", s.handleUpdateFeedInterval)
109 r.Get("/discover-url", s.handleDiscoverFeedURL)109 r.Get("/discover-url", s.handleDiscoverFeedURL)
110+ r.Post("/clear", s.handleClearAllSubscriptions)
110 })111 })
111 112
112 s.router.Route("/articles", func(r chi.Router) {113 s.router.Route("/articles", func(r chi.Router) {
modified internal/tmpl/dashboard.html +0 -11
@@ -103,17 +103,6 @@
103103 </div>
104104 </div>
105105 {{end}}
106-
107- <div>
108- <h2 class="text-lg font-semibold text-spot-text mb-4">Quick add</h2>
109- <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="flex gap-2">
110- {{csrfInput .CSRFToken}}
111- <input type="url" name="feed_url" placeholder="Feed URL"
112- class="flex-1 bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
113- required>
114- <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
115- </form>
116- </div>
117106 </div>
118107 </div>
119108 {{end}}
@@ -103,17 +103,6 @@
103 </div>103 </div>
104 </div>104 </div>
105 {{end}}105 {{end}}
106-
107- <div>
108- <h2 class="text-lg font-semibold text-spot-text mb-4">Quick add</h2>
109- <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="flex gap-2">
110- {{csrfInput .CSRFToken}}
111- <input type="url" name="feed_url" placeholder="Feed URL"
112- class="flex-1 bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
113- required>
114- <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
115- </form>
116- </div>
117 </div>106 </div>
118 </div>107 </div>
119 {{end}}108 {{end}}
modified internal/tmpl/feeds.html +28 -16
@@ -1,6 +1,6 @@
11 {{define "feeds.html"}}
22 <div class="flex items-center justify-between mb-2">
3- <h1 class="text-2xl font-bold text-spot-text">Feeds</h1>
3+ <h1 class="text-2xl font-bold text-spot-text">Feeds <span class="text-base font-normal text-spot-secondary">({{.SubscriptionCount}})</span></h1>
44 <div class="flex items-center gap-3">
55 <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML"
66 hx-indicator="#refresh-indicator"
@@ -53,15 +53,17 @@
5353
5454 <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider">
5555 {{range .Subscriptions}}
56- <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl">
57- <div class="min-w-0">
58- <div class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div>
59- {{if and .FeedTitle .FeedURL}}<div class="text-xs text-spot-muted truncate">{{.FeedURL}}</div>{{end}}
60- {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}}
56+ <div class="feed-item px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl">
57+ <div class="min-w-0 flex-1">
58+ <div class="flex items-center gap-2">
59+ <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
60+ {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}}
61+ </div>
62+ <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div>
6163 </div>
6264 <div class="flex items-center gap-3">
6365 {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}}
64- <form hx-post="/feeds/set-interval" hx-swap="none" hx-vals='{"feed_url": "{{.FeedURL}}"}' class="inline-flex items-center gap-1">
66+ <form hx-post="/feeds/set-interval" hx-swap="none" class="inline-flex items-center gap-1">
6567 {{csrfInput $.CSRFToken}}
6668 <input type="hidden" name="feed_url" value="{{.FeedURL}}">
6769 <select name="interval" onchange="this.form.submit()"
@@ -75,7 +77,7 @@
7577 <option value="1440" {{if eq .FetchInterval 1440}}selected{{end}}>24h</option>
7678 </select>
7779 </form>
78- <form hx-delete="/feeds/remove" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"
80+ <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s"
7981 hx-confirm="Unsubscribe from this feed?" class="inline">
8082 {{csrfInput $.CSRFToken}}
8183 <input type="hidden" name="url" value="{{.FeedURL}}">
@@ -85,7 +87,7 @@
8587 </div>
8688 {{else}}
8789 <div class="px-4 py-8 text-center text-spot-secondary">
88- <p>No feeds yet. Add one below!</p>
90+ <p>No feeds yet. Add one from the sidebar.</p>
8991 </div>
9092 {{end}}
9193 </div>
@@ -94,7 +96,7 @@
9496 <div class="text-center py-4">
9597 <button hx-get="/feeds?offset={{.Page.NextOffset}}{{if .Category}}&category={{.Category}}{{end}}"
9698 hx-target="#feed-list" hx-swap="beforeend"
97- hx-select=".px-5"
99+ hx-select=".feed-item"
98100 class="text-sm text-spot-secondary hover:text-spot-green transition">
99101 Load more
100102 </button>
@@ -114,12 +116,15 @@
114116 class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
115117 <button type="submit" class="w-full bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
116118 </form>
117- <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2">
118- {{csrfInput .CSRFToken}}
119- <label class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import OPML</label>
120- <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
121- <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import</button>
122- </form>
119+ <div class="border-t border-spot-divider pt-4 space-y-3">
120+ <h2 class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import / Export</h2>
121+ <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2">
122+ {{csrfInput .CSRFToken}}
123+ <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
124+ <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import OPML</button>
125+ </form>
126+ <a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a>
127+ </div>
123128 </div>
124129
125130 {{if .PeopleRecommendations}}
@@ -139,6 +144,13 @@
139144 </div>
140145 </div>
141146 {{end}}
147+
148+ {{if .Subscriptions}}
149+ <form hx-post="/feeds/clear" hx-confirm="Are you sure you want to unsubscribe from ALL feeds? This cannot be undone.">
150+ {{csrfInput .CSRFToken}}
151+ <button type="submit" class="w-full border border-spot-red/50 text-spot-red rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-red hover:bg-spot-red/10 transition">Clear all subscriptions</button>
152+ </form>
153+ {{end}}
142154 </div>
143155 </div>
144156 {{end}}
@@ -1,6 +1,6 @@
1 {{define "feeds.html"}}1 {{define "feeds.html"}}
2 <div class="flex items-center justify-between mb-2">2 <div class="flex items-center justify-between mb-2">
3- <h1 class="text-2xl font-bold text-spot-text">Feeds</h1>3+ <h1 class="text-2xl font-bold text-spot-text">Feeds <span class="text-base font-normal text-spot-secondary">({{.SubscriptionCount}})</span></h1>
4 <div class="flex items-center gap-3">4 <div class="flex items-center gap-3">
5 <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML"5 <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML"
6 hx-indicator="#refresh-indicator"6 hx-indicator="#refresh-indicator"
@@ -53,15 +53,17 @@
53 53
54 <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider">54 <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider">
55 {{range .Subscriptions}}55 {{range .Subscriptions}}
56- <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl">56+ <div class="feed-item px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl">
57- <div class="min-w-0">57+ <div class="min-w-0 flex-1">
58- <div class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div>58+ <div class="flex items-center gap-2">
59- {{if and .FeedTitle .FeedURL}}<div class="text-xs text-spot-muted truncate">{{.FeedURL}}</div>{{end}}59+ <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
60- {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}}60+ {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}}
61+ </div>
62+ <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div>
61 </div>63 </div>
62 <div class="flex items-center gap-3">64 <div class="flex items-center gap-3">
63 {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}}65 {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}}
64- <form hx-post="/feeds/set-interval" hx-swap="none" hx-vals='{"feed_url": "{{.FeedURL}}"}' class="inline-flex items-center gap-1">66+ <form hx-post="/feeds/set-interval" hx-swap="none" class="inline-flex items-center gap-1">
65 {{csrfInput $.CSRFToken}}67 {{csrfInput $.CSRFToken}}
66 <input type="hidden" name="feed_url" value="{{.FeedURL}}">68 <input type="hidden" name="feed_url" value="{{.FeedURL}}">
67 <select name="interval" onchange="this.form.submit()"69 <select name="interval" onchange="this.form.submit()"
@@ -75,7 +77,7 @@
75 <option value="1440" {{if eq .FetchInterval 1440}}selected{{end}}>24h</option>77 <option value="1440" {{if eq .FetchInterval 1440}}selected{{end}}>24h</option>
76 </select>78 </select>
77 </form>79 </form>
78- <form hx-delete="/feeds/remove" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s"80+ <form hx-delete="/feeds/remove" hx-target="closest .feed-item" hx-swap="outerHTML swap:0.3s"
79 hx-confirm="Unsubscribe from this feed?" class="inline">81 hx-confirm="Unsubscribe from this feed?" class="inline">
80 {{csrfInput $.CSRFToken}}82 {{csrfInput $.CSRFToken}}
81 <input type="hidden" name="url" value="{{.FeedURL}}">83 <input type="hidden" name="url" value="{{.FeedURL}}">
@@ -85,7 +87,7 @@
85 </div>87 </div>
86 {{else}}88 {{else}}
87 <div class="px-4 py-8 text-center text-spot-secondary">89 <div class="px-4 py-8 text-center text-spot-secondary">
88- <p>No feeds yet. Add one below!</p>90+ <p>No feeds yet. Add one from the sidebar.</p>
89 </div>91 </div>
90 {{end}}92 {{end}}
91 </div>93 </div>
@@ -94,7 +96,7 @@
94 <div class="text-center py-4">96 <div class="text-center py-4">
95 <button hx-get="/feeds?offset={{.Page.NextOffset}}{{if .Category}}&category={{.Category}}{{end}}"97 <button hx-get="/feeds?offset={{.Page.NextOffset}}{{if .Category}}&category={{.Category}}{{end}}"
96 hx-target="#feed-list" hx-swap="beforeend"98 hx-target="#feed-list" hx-swap="beforeend"
97- hx-select=".px-5"99+ hx-select=".feed-item"
98 class="text-sm text-spot-secondary hover:text-spot-green transition">100 class="text-sm text-spot-secondary hover:text-spot-green transition">
99 Load more101 Load more
100 </button>102 </button>
@@ -114,12 +116,15 @@
114 class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">116 class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
115 <button type="submit" class="w-full bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>117 <button type="submit" class="w-full bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
116 </form>118 </form>
117- <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2">119+ <div class="border-t border-spot-divider pt-4 space-y-3">
118- {{csrfInput .CSRFToken}}120+ <h2 class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import / Export</h2>
119- <label class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import OPML</label>121+ <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2">
120- <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">122+ {{csrfInput .CSRFToken}}
121- <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import</button>123+ <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
122- </form>124+ <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import OPML</button>
125+ </form>
126+ <a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a>
127+ </div>
123 </div>128 </div>
124 129
125 {{if .PeopleRecommendations}}130 {{if .PeopleRecommendations}}
@@ -139,6 +144,13 @@
139 </div>144 </div>
140 </div>145 </div>
141 {{end}}146 {{end}}
147+
148+ {{if .Subscriptions}}
149+ <form hx-post="/feeds/clear" hx-confirm="Are you sure you want to unsubscribe from ALL feeds? This cannot be undone.">
150+ {{csrfInput .CSRFToken}}
151+ <button type="submit" class="w-full border border-spot-red/50 text-spot-red rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-red hover:bg-spot-red/10 transition">Clear all subscriptions</button>
152+ </form>
153+ {{end}}
142 </div>154 </div>
143 </div>155 </div>
144 {{end}}156 {{end}}