Add subscription title storage and clear all featureUnverified
a6093b7 parent: b7c422e modified
internal/atproto/firehose_handler.go +1 -1 | @@ -52,7 +52,7 @@ func (h *FirehoseDBHandler) handleSubscription(ctx context.Context, event *Fireh | ||
| 52 | 52 | |
| 53 | 53 | f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)} |
| 54 | 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 | 57 | case "delete": |
| 58 | 58 | 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 | ||
| 80 | 80 | f := &db.Feed{FeedURL: rec.FeedURL, Title: db.NullStr(rec.Title)} |
| 81 | 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 | 86 | 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) | ||
| 25 | 25 | cursor := r.URL.Query().Get("cursor") |
| 26 | 26 | |
| 27 | 27 | 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 | |
| 29 | 29 | FROM subscriptions s |
| 30 | 30 | JOIN feeds f ON s.feed_url = f.feed_url |
| 31 | 31 | WHERE s.user_did = ?` |
| @@ -428,7 +428,7 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) { | ||
| 428 | 428 | } |
| 429 | 429 | |
| 430 | 430 | 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 | |
| 432 | 432 | FROM subscriptions s |
| 433 | 433 | JOIN feeds f ON s.feed_url = f.feed_url |
| 434 | 434 | 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_at | 28 | + SELECT s.id, f.feed_url, COALESCE(s.title, f.title), s.category, s.added_at |
| 29 | FROM subscriptions s | 29 | FROM subscriptions s |
| 30 | JOIN feeds f ON s.feed_url = f.feed_url | 30 | 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.category | 431 | + SELECT s.feed_url, COALESCE(s.title, f.title), s.category |
| 432 | FROM subscriptions s | 432 | FROM subscriptions s |
| 433 | JOIN feeds f ON s.feed_url = f.feed_url | 433 | 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{ | ||
| 85 | 85 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 86 | 86 | user_did TEXT NOT NULL REFERENCES users(did), |
| 87 | 87 | feed_url TEXT NOT NULL REFERENCES feeds(feed_url), |
| 88 | + title TEXT, | |
| 88 | 89 | category TEXT, |
| 89 | 90 | added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 90 | 91 | 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 | ||
| 126 | 126 | 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 | 130 | _, 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)) | |
| 134 | 134 | if err != nil { |
| 135 | 135 | return err |
| 136 | 136 | } |
| @@ -151,6 +151,13 @@ func uriOrNil(category, v string) any { | ||
| 151 | 151 | 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 | 161 | func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error { |
| 155 | 162 | _, err := db.ExecContext(ctx, ` |
| 156 | 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 | 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 | 207 | func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) { |
| 165 | 208 | s := &Subscription{} |
| 166 | 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 | 211 | COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid |
| 169 | 212 | FROM subscriptions s |
| 170 | 213 | 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 | 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 | 224 | COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid |
| 182 | 225 | FROM subscriptions s |
| 183 | 226 | 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 err | 126 | 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 err | 135 | return err |
| 136 | } | 136 | } |
| @@ -151,6 +151,13 @@ func uriOrNil(category, v string) any { | |||
| 151 | return v | 151 | 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.cid | 211 | COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid |
| 169 | FROM subscriptions s | 212 | FROM subscriptions s |
| 170 | LEFT JOIN feeds f ON s.feed_url = f.feed_url | 213 | 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.cid | 224 | COALESCE(f.fetch_interval_minutes, 30), s.uri, s.cid |
| 182 | FROM subscriptions s | 225 | FROM subscriptions s |
| 183 | LEFT JOIN feeds f ON s.feed_url = f.feed_url | 226 | LEFT JOIN feeds f ON s.feed_url = f.feed_url |
modified
internal/feed/opml.go +61 -29 | @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "encoding/xml" |
| 6 | 6 | "io" |
| 7 | + "strings" | |
| 7 | 8 | ) |
| 8 | 9 | |
| 9 | 10 | type OPML struct { |
| @@ -18,30 +19,60 @@ type OPML struct { | ||
| 18 | 19 | } |
| 19 | 20 | |
| 20 | 21 | 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"` | |
| 26 | 28 | } |
| 27 | 29 | |
| 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 "" | |
| 32 | 44 | } |
| 33 | 45 | |
| 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 | |
| 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 | 70 | 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 | 74 | return nil, err |
| 43 | 75 | } |
| 44 | - | |
| 45 | 76 | return &opml, nil |
| 46 | 77 | } |
| 47 | 78 | |
| @@ -53,18 +84,17 @@ func ExtractFeedURLs(opml *OPML) []FeedURL { | ||
| 53 | 84 | |
| 54 | 85 | func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) { |
| 55 | 86 | for _, o := range outlines { |
| 56 | - if o.XMLURL != "" { | |
| 87 | + if o.IsSubscription() { | |
| 57 | 88 | *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, | |
| 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 | 105 | opml.Head.Title = title |
| 76 | 106 | |
| 77 | 107 | 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) | |
| 83 | 115 | } |
| 84 | 116 | |
| 85 | 117 | 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 string | 31 | + if o.Title != "" { |
| 30 | - Title string | 32 | + return o.Title |
| 31 | - Category string | 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 "" | ||
| 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 OPML | 70 | 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, err | 74 | return nil, err |
| 43 | } | 75 | } |
| 44 | - | ||
| 45 | return &opml, nil | 76 | 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 = title | 105 | 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.Buffer | 117 | 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) { | ||
| 42 | 42 | s.render(w, r, "feeds.html", map[string]any{ |
| 43 | 43 | "User": user, |
| 44 | 44 | "Subscriptions": subs, |
| 45 | + "SubscriptionCount": len(allSubs), | |
| 45 | 46 | "Categories": categories, |
| 46 | 47 | "Category": category, |
| 47 | 48 | "FeedRecommendations": feedRecs, |
| @@ -116,7 +117,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 116 | 117 | 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 | 121 | s.logger.Error("failed to create subscription", "error", err) |
| 121 | 122 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 122 | 123 | return |
| @@ -158,7 +159,34 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { | ||
| 158 | 159 | 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 | 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 | 209 | client := s.pdsClientForUser(r) |
| 182 | 210 | for _, fu := range feedURLs { |
| 183 | 211 | 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), | |
| 186 | 216 | } |
| 187 | 217 | if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil { |
| 188 | 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 | 236 | 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 | 240 | s.logger.Error("failed to create subscription", "error", subErr) |
| 211 | 241 | continue |
| 212 | 242 | } |
| 213 | 243 | added++ |
| 214 | 244 | } |
| 215 | 245 | |
| 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) | |
| 222 | 248 | } |
| 223 | 249 | |
| 224 | 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 | 254 | var feedURLs []feed.FeedURL |
| 229 | 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 | 256 | feedURLs = append(feedURLs, feed.FeedURL{ |
| 236 | 257 | URL: sub.FeedURL, |
| 237 | - Title: title, | |
| 258 | + Title: sub.FeedTitle, | |
| 238 | 259 | }) |
| 239 | 260 | } |
| 240 | 261 | |
| @@ -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 = cid | 117 | 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 | return | 123 | return |
| @@ -158,7 +159,34 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { | |||
| 158 | return | 159 | 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 = cid | 236 | 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 | continue | 241 | 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.FeedURL | 254 | 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() { | ||
| 107 | 107 | r.Get("/list", s.handleFeedList) |
| 108 | 108 | r.Post("/set-interval", s.handleUpdateFeedInterval) |
| 109 | 109 | r.Get("/discover-url", s.handleDiscoverFeedURL) |
| 110 | + r.Post("/clear", s.handleClearAllSubscriptions) | |
| 110 | 111 | }) |
| 111 | 112 | |
| 112 | 113 | 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 @@ | ||
| 103 | 103 | </div> |
| 104 | 104 | </div> |
| 105 | 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 | 106 | </div> |
| 118 | 107 | </div> |
| 119 | 108 | {{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 @@ | ||
| 1 | 1 | {{define "feeds.html"}} |
| 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 | 4 | <div class="flex items-center gap-3"> |
| 5 | 5 | <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML" |
| 6 | 6 | hx-indicator="#refresh-indicator" |
| @@ -53,15 +53,17 @@ | ||
| 53 | 53 | |
| 54 | 54 | <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider"> |
| 55 | 55 | {{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> | |
| 61 | 63 | </div> |
| 62 | 64 | <div class="flex items-center gap-3"> |
| 63 | 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 | 67 | {{csrfInput $.CSRFToken}} |
| 66 | 68 | <input type="hidden" name="feed_url" value="{{.FeedURL}}"> |
| 67 | 69 | <select name="interval" onchange="this.form.submit()" |
| @@ -75,7 +77,7 @@ | ||
| 75 | 77 | <option value="1440" {{if eq .FetchInterval 1440}}selected{{end}}>24h</option> |
| 76 | 78 | </select> |
| 77 | 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 | 81 | hx-confirm="Unsubscribe from this feed?" class="inline"> |
| 80 | 82 | {{csrfInput $.CSRFToken}} |
| 81 | 83 | <input type="hidden" name="url" value="{{.FeedURL}}"> |
| @@ -85,7 +87,7 @@ | ||
| 85 | 87 | </div> |
| 86 | 88 | {{else}} |
| 87 | 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 | 91 | </div> |
| 90 | 92 | {{end}} |
| 91 | 93 | </div> |
| @@ -94,7 +96,7 @@ | ||
| 94 | 96 | <div class="text-center py-4"> |
| 95 | 97 | <button hx-get="/feeds?offset={{.Page.NextOffset}}{{if .Category}}&category={{.Category}}{{end}}" |
| 96 | 98 | hx-target="#feed-list" hx-swap="beforeend" |
| 97 | - hx-select=".px-5" | |
| 99 | + hx-select=".feed-item" | |
| 98 | 100 | class="text-sm text-spot-secondary hover:text-spot-green transition"> |
| 99 | 101 | Load more |
| 100 | 102 | </button> |
| @@ -114,12 +116,15 @@ | ||
| 114 | 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 | 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 | 118 | </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> | |
| 123 | 128 | </div> |
| 124 | 129 | |
| 125 | 130 | {{if .PeopleRecommendations}} |
| @@ -139,6 +144,13 @@ | ||
| 139 | 144 | </div> |
| 140 | 145 | </div> |
| 141 | 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 | 154 | </div> |
| 143 | 155 | </div> |
| 144 | 156 | {{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 more | 101 | 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}} |