nandi/gleanpublic Fork 0
4a3040f
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 category filtering and sort ordering to articles listingUnverified

Julien Robert committed 2026-05-26T23:30:30+02:00 Browse files
4a3040f parent: b75bc69
modified internal/db/article.go +63 -16
@@ -11,7 +11,8 @@ import (
1111 "pkg.rbrt.fr/glean/internal/feed"
1212 )
1313
14-const articlesOrderBy = ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
14+const articlesOrderByDesc = ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
15+const articlesOrderByAsc = ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published ASC LIMIT ? OFFSET ?`
1516
1617 type ArticleStore struct {
1718 db *DB
@@ -110,9 +111,16 @@ func (s *ArticleStore) GetArticle(ctx context.Context, id int64) (*Article, erro
110111 return a, nil
111112 }
112113
113-func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
114- var query string
115- var args []any
114+func (s *ArticleStore) ListArticles(
115+ ctx context.Context,
116+ userDID, feedURL, category string,
117+ limit, offset int,
118+ sortOldest bool,
119+) ([]*Article, error) {
120+ var (
121+ query string
122+ args []any
123+ )
116124
117125 if feedURL != "" {
118126 query = `
@@ -141,10 +149,14 @@ func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string
141149 WHERE 1=1
142150 `
143151 args = []any{userDID, userDID, userDID}
152+ query, args = addCategoryWhere(query, args, category)
144153 }
145154
146- // Future-published articles (e.g., scheduled) sort last
147- query += articlesOrderBy
155+ orderBy := articlesOrderByDesc
156+ if sortOldest {
157+ orderBy = articlesOrderByAsc
158+ }
159+ query += orderBy
148160 args = append(args, limit, offset)
149161
150162 rows, err := s.db.QueryContext(ctx, query, args...)
@@ -166,7 +178,26 @@ func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string
166178 return articles, rows.Err()
167179 }
168180
169-func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
181+func addCategoryWhere(query string, args []any, category string) (string, []any) {
182+ switch category {
183+ case "__none__":
184+ query += ` AND (s.category IS NULL OR s.category = '')`
185+ case "":
186+ // no-op
187+ default:
188+ query += ` AND s.category = ?`
189+ args = append(args, category)
190+ }
191+
192+ return query, args
193+}
194+
195+func (s *ArticleStore) ListUnreadArticles(
196+ ctx context.Context,
197+ userDID, feedURL, category string,
198+ limit, offset int,
199+ sortOldest bool,
200+) ([]*Article, error) {
170201 var query string
171202 var args []any
172203
@@ -197,10 +228,14 @@ func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL
197228 WHERE (r.is_read = 0 OR r.is_read IS NULL)
198229 `
199230 args = []any{userDID, userDID, userDID}
231+ query, args = addCategoryWhere(query, args, category)
200232 }
201233
202- // Future-published articles (e.g., scheduled) sort last
203- query += articlesOrderBy
234+ orderBy := articlesOrderByDesc
235+ if sortOldest {
236+ orderBy = articlesOrderByAsc
237+ }
238+ query += orderBy
204239 args = append(args, limit, offset)
205240
206241 rows, err := s.db.QueryContext(ctx, query, args...)
@@ -222,7 +257,12 @@ func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL
222257 return articles, rows.Err()
223258 }
224259
225-func (s *ArticleStore) ListReadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
260+func (s *ArticleStore) ListReadArticles(
261+ ctx context.Context,
262+ userDID, feedURL, category string,
263+ limit, offset int,
264+ sortOldest bool,
265+) ([]*Article, error) {
226266 var query string
227267 var args []any
228268
@@ -253,10 +293,14 @@ func (s *ArticleStore) ListReadArticles(ctx context.Context, userDID, feedURL st
253293 WHERE r.is_read = 1
254294 `
255295 args = []any{userDID, userDID, userDID}
296+ query, args = addCategoryWhere(query, args, category)
256297 }
257298
258- // Future-published articles (e.g., scheduled) sort last
259- query += articlesOrderBy
299+ orderBy := articlesOrderByDesc
300+ if sortOldest {
301+ orderBy = articlesOrderByAsc
302+ }
303+ query += orderBy
260304 args = append(args, limit, offset)
261305
262306 rows, err := s.db.QueryContext(ctx, query, args...)
@@ -363,7 +407,7 @@ func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, article
363407 return rs, nil
364408 }
365409
366-func (s *ArticleStore) GetUnreadCount(ctx context.Context, userDID, feedURL string) (int, error) {
410+func (s *ArticleStore) GetUnreadCount(ctx context.Context, userDID, feedURL, category string) (int, error) {
367411 var count int
368412 if feedURL != "" {
369413 err := s.db.QueryRowContext(ctx, `
@@ -374,13 +418,16 @@ func (s *ArticleStore) GetUnreadCount(ctx context.Context, userDID, feedURL stri
374418 `, userDID, feedURL).Scan(&count)
375419 return count, err
376420 }
377- err := s.db.QueryRowContext(ctx, `
421+ query := `
378422 SELECT COUNT(*)
379423 FROM articles.articles a
380424 JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
381425 LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id
382- WHERE r.is_read = 0 OR r.is_read IS NULL
383- `, userDID, userDID).Scan(&count)
426+ WHERE (r.is_read = 0 OR r.is_read IS NULL)
427+ `
428+ args := []any{userDID, userDID}
429+ query, args = addCategoryWhere(query, args, category)
430+ err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
384431 return count, err
385432 }
386433
@@ -11,7 +11,8 @@ import (
11 "pkg.rbrt.fr/glean/internal/feed"11 "pkg.rbrt.fr/glean/internal/feed"
12 )12 )
13 13
14-const articlesOrderBy = ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`14+const articlesOrderByDesc = ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
15+const articlesOrderByAsc = ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published ASC LIMIT ? OFFSET ?`
15 16
16 type ArticleStore struct {17 type ArticleStore struct {
17 db *DB18 db *DB
@@ -110,9 +111,16 @@ func (s *ArticleStore) GetArticle(ctx context.Context, id int64) (*Article, erro
110 return a, nil111 return a, nil
111 }112 }
112 113
113-func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {114+func (s *ArticleStore) ListArticles(
114- var query string115+ ctx context.Context,
115- var args []any116+ userDID, feedURL, category string,
117+ limit, offset int,
118+ sortOldest bool,
119+) ([]*Article, error) {
120+ var (
121+ query string
122+ args []any
123+ )
116 124
117 if feedURL != "" {125 if feedURL != "" {
118 query = `126 query = `
@@ -141,10 +149,14 @@ func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string
141 WHERE 1=1149 WHERE 1=1
142 `150 `
143 args = []any{userDID, userDID, userDID}151 args = []any{userDID, userDID, userDID}
152+ query, args = addCategoryWhere(query, args, category)
144 }153 }
145 154
146- // Future-published articles (e.g., scheduled) sort last155+ orderBy := articlesOrderByDesc
147- query += articlesOrderBy156+ if sortOldest {
157+ orderBy = articlesOrderByAsc
158+ }
159+ query += orderBy
148 args = append(args, limit, offset)160 args = append(args, limit, offset)
149 161
150 rows, err := s.db.QueryContext(ctx, query, args...)162 rows, err := s.db.QueryContext(ctx, query, args...)
@@ -166,7 +178,26 @@ func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string
166 return articles, rows.Err()178 return articles, rows.Err()
167 }179 }
168 180
169-func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {181+func addCategoryWhere(query string, args []any, category string) (string, []any) {
182+ switch category {
183+ case "__none__":
184+ query += ` AND (s.category IS NULL OR s.category = '')`
185+ case "":
186+ // no-op
187+ default:
188+ query += ` AND s.category = ?`
189+ args = append(args, category)
190+ }
191+
192+ return query, args
193+}
194+
195+func (s *ArticleStore) ListUnreadArticles(
196+ ctx context.Context,
197+ userDID, feedURL, category string,
198+ limit, offset int,
199+ sortOldest bool,
200+) ([]*Article, error) {
170 var query string201 var query string
171 var args []any202 var args []any
172 203
@@ -197,10 +228,14 @@ func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL
197 WHERE (r.is_read = 0 OR r.is_read IS NULL)228 WHERE (r.is_read = 0 OR r.is_read IS NULL)
198 `229 `
199 args = []any{userDID, userDID, userDID}230 args = []any{userDID, userDID, userDID}
231+ query, args = addCategoryWhere(query, args, category)
200 }232 }
201 233
202- // Future-published articles (e.g., scheduled) sort last234+ orderBy := articlesOrderByDesc
203- query += articlesOrderBy235+ if sortOldest {
236+ orderBy = articlesOrderByAsc
237+ }
238+ query += orderBy
204 args = append(args, limit, offset)239 args = append(args, limit, offset)
205 240
206 rows, err := s.db.QueryContext(ctx, query, args...)241 rows, err := s.db.QueryContext(ctx, query, args...)
@@ -222,7 +257,12 @@ func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL
222 return articles, rows.Err()257 return articles, rows.Err()
223 }258 }
224 259
225-func (s *ArticleStore) ListReadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {260+func (s *ArticleStore) ListReadArticles(
261+ ctx context.Context,
262+ userDID, feedURL, category string,
263+ limit, offset int,
264+ sortOldest bool,
265+) ([]*Article, error) {
226 var query string266 var query string
227 var args []any267 var args []any
228 268
@@ -253,10 +293,14 @@ func (s *ArticleStore) ListReadArticles(ctx context.Context, userDID, feedURL st
253 WHERE r.is_read = 1293 WHERE r.is_read = 1
254 `294 `
255 args = []any{userDID, userDID, userDID}295 args = []any{userDID, userDID, userDID}
296+ query, args = addCategoryWhere(query, args, category)
256 }297 }
257 298
258- // Future-published articles (e.g., scheduled) sort last299+ orderBy := articlesOrderByDesc
259- query += articlesOrderBy300+ if sortOldest {
301+ orderBy = articlesOrderByAsc
302+ }
303+ query += orderBy
260 args = append(args, limit, offset)304 args = append(args, limit, offset)
261 305
262 rows, err := s.db.QueryContext(ctx, query, args...)306 rows, err := s.db.QueryContext(ctx, query, args...)
@@ -363,7 +407,7 @@ func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, article
363 return rs, nil407 return rs, nil
364 }408 }
365 409
366-func (s *ArticleStore) GetUnreadCount(ctx context.Context, userDID, feedURL string) (int, error) {410+func (s *ArticleStore) GetUnreadCount(ctx context.Context, userDID, feedURL, category string) (int, error) {
367 var count int411 var count int
368 if feedURL != "" {412 if feedURL != "" {
369 err := s.db.QueryRowContext(ctx, `413 err := s.db.QueryRowContext(ctx, `
@@ -374,13 +418,16 @@ func (s *ArticleStore) GetUnreadCount(ctx context.Context, userDID, feedURL stri
374 `, userDID, feedURL).Scan(&count)418 `, userDID, feedURL).Scan(&count)
375 return count, err419 return count, err
376 }420 }
377- err := s.db.QueryRowContext(ctx, `421+ query := `
378 SELECT COUNT(*)422 SELECT COUNT(*)
379 FROM articles.articles a423 FROM articles.articles a
380 JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?424 JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
381 LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id425 LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id
382- WHERE r.is_read = 0 OR r.is_read IS NULL426+ WHERE (r.is_read = 0 OR r.is_read IS NULL)
383- `, userDID, userDID).Scan(&count)427+ `
428+ args := []any{userDID, userDID}
429+ query, args = addCategoryWhere(query, args, category)
430+ err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
384 return count, err431 return count, err
385 }432 }
386 433
modified internal/db/article_test.go +9 -9
@@ -63,7 +63,7 @@ func TestListReadArticles_ReturnsOnlyRead(t *testing.T) {
6363 dbs := setupTestDB(t)
6464 userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, dbs)
6565
66- results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)
66+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, "", 10, 0, false)
6767 assert.NilError(t, err)
6868 assert.Equal(t, len(results), 1)
6969 assert.Equal(t, results[0].ID, readID)
@@ -77,7 +77,7 @@ func TestListReadArticles_ExcludesUnread(t *testing.T) {
7777 dbs := setupTestDB(t)
7878 userDID, feedURL, _, unreadID := seedArticleReadState(t, ctx, dbs)
7979
80- results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)
80+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, "", 10, 0, false)
8181 assert.NilError(t, err)
8282 for _, a := range results {
8383 assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list")
@@ -89,7 +89,7 @@ func TestListUnreadArticles_ReturnsOnlyUnread(t *testing.T) {
8989 dbs := setupTestDB(t)
9090 userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, dbs)
9191
92- results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
92+ results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, "", 10, 0, false)
9393 assert.NilError(t, err)
9494 assert.Equal(t, len(results), 1)
9595 assert.Equal(t, results[0].ID, unreadID)
@@ -103,7 +103,7 @@ func TestListArticles_ReturnsAll(t *testing.T) {
103103 dbs := setupTestDB(t)
104104 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)
105105
106- results, err := dbs.Articles.ListArticles(ctx, userDID, feedURL, 10, 0)
106+ results, err := dbs.Articles.ListArticles(ctx, userDID, feedURL, "", 10, 0, false)
107107 assert.NilError(t, err)
108108 assert.Equal(t, len(results), 2)
109109 }
@@ -133,7 +133,7 @@ func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) {
133133 dbs := setupTestDB(t)
134134 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)
135135
136- results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
136+ results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, "", 10, 0, false)
137137 assert.NilError(t, err)
138138 assert.Equal(t, len(results), 1)
139139 }
@@ -143,11 +143,11 @@ func TestListReadArticles_WithFeedURLFilter(t *testing.T) {
143143 dbs := setupTestDB(t)
144144 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)
145145
146- results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)
146+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, "", 10, 0, false)
147147 assert.NilError(t, err)
148148 assert.Equal(t, len(results), 1)
149149
150- results, err = dbs.Articles.ListReadArticles(ctx, userDID, "https://other.com/feed", 10, 0)
150+ results, err = dbs.Articles.ListReadArticles(ctx, userDID, "https://other.com/feed", "", 10, 0, false)
151151 assert.NilError(t, err)
152152 assert.Equal(t, len(results), 0)
153153 }
@@ -157,11 +157,11 @@ func TestGetUnreadCount(t *testing.T) {
157157 dbs := setupTestDB(t)
158158 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)
159159
160- count, err := dbs.Articles.GetUnreadCount(ctx, userDID, feedURL)
160+ count, err := dbs.Articles.GetUnreadCount(ctx, userDID, feedURL, "")
161161 assert.NilError(t, err)
162162 assert.Equal(t, count, 1)
163163
164- count, err = dbs.Articles.GetUnreadCount(ctx, userDID, "")
164+ count, err = dbs.Articles.GetUnreadCount(ctx, userDID, "", "")
165165 assert.NilError(t, err)
166166 assert.Equal(t, count, 1)
167167 }
@@ -63,7 +63,7 @@ func TestListReadArticles_ReturnsOnlyRead(t *testing.T) {
63 dbs := setupTestDB(t)63 dbs := setupTestDB(t)
64 userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, dbs)64 userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, dbs)
65 65
66- results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)66+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, "", 10, 0, false)
67 assert.NilError(t, err)67 assert.NilError(t, err)
68 assert.Equal(t, len(results), 1)68 assert.Equal(t, len(results), 1)
69 assert.Equal(t, results[0].ID, readID)69 assert.Equal(t, results[0].ID, readID)
@@ -77,7 +77,7 @@ func TestListReadArticles_ExcludesUnread(t *testing.T) {
77 dbs := setupTestDB(t)77 dbs := setupTestDB(t)
78 userDID, feedURL, _, unreadID := seedArticleReadState(t, ctx, dbs)78 userDID, feedURL, _, unreadID := seedArticleReadState(t, ctx, dbs)
79 79
80- results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)80+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, "", 10, 0, false)
81 assert.NilError(t, err)81 assert.NilError(t, err)
82 for _, a := range results {82 for _, a := range results {
83 assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list")83 assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list")
@@ -89,7 +89,7 @@ func TestListUnreadArticles_ReturnsOnlyUnread(t *testing.T) {
89 dbs := setupTestDB(t)89 dbs := setupTestDB(t)
90 userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, dbs)90 userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, dbs)
91 91
92- results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)92+ results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, "", 10, 0, false)
93 assert.NilError(t, err)93 assert.NilError(t, err)
94 assert.Equal(t, len(results), 1)94 assert.Equal(t, len(results), 1)
95 assert.Equal(t, results[0].ID, unreadID)95 assert.Equal(t, results[0].ID, unreadID)
@@ -103,7 +103,7 @@ func TestListArticles_ReturnsAll(t *testing.T) {
103 dbs := setupTestDB(t)103 dbs := setupTestDB(t)
104 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)104 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)
105 105
106- results, err := dbs.Articles.ListArticles(ctx, userDID, feedURL, 10, 0)106+ results, err := dbs.Articles.ListArticles(ctx, userDID, feedURL, "", 10, 0, false)
107 assert.NilError(t, err)107 assert.NilError(t, err)
108 assert.Equal(t, len(results), 2)108 assert.Equal(t, len(results), 2)
109 }109 }
@@ -133,7 +133,7 @@ func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) {
133 dbs := setupTestDB(t)133 dbs := setupTestDB(t)
134 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)134 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)
135 135
136- results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)136+ results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, "", 10, 0, false)
137 assert.NilError(t, err)137 assert.NilError(t, err)
138 assert.Equal(t, len(results), 1)138 assert.Equal(t, len(results), 1)
139 }139 }
@@ -143,11 +143,11 @@ func TestListReadArticles_WithFeedURLFilter(t *testing.T) {
143 dbs := setupTestDB(t)143 dbs := setupTestDB(t)
144 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)144 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)
145 145
146- results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)146+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, "", 10, 0, false)
147 assert.NilError(t, err)147 assert.NilError(t, err)
148 assert.Equal(t, len(results), 1)148 assert.Equal(t, len(results), 1)
149 149
150- results, err = dbs.Articles.ListReadArticles(ctx, userDID, "https://other.com/feed", 10, 0)150+ results, err = dbs.Articles.ListReadArticles(ctx, userDID, "https://other.com/feed", "", 10, 0, false)
151 assert.NilError(t, err)151 assert.NilError(t, err)
152 assert.Equal(t, len(results), 0)152 assert.Equal(t, len(results), 0)
153 }153 }
@@ -157,11 +157,11 @@ func TestGetUnreadCount(t *testing.T) {
157 dbs := setupTestDB(t)157 dbs := setupTestDB(t)
158 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)158 userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs)
159 159
160- count, err := dbs.Articles.GetUnreadCount(ctx, userDID, feedURL)160+ count, err := dbs.Articles.GetUnreadCount(ctx, userDID, feedURL, "")
161 assert.NilError(t, err)161 assert.NilError(t, err)
162 assert.Equal(t, count, 1)162 assert.Equal(t, count, 1)
163 163
164- count, err = dbs.Articles.GetUnreadCount(ctx, userDID, "")164+ count, err = dbs.Articles.GetUnreadCount(ctx, userDID, "", "")
165 assert.NilError(t, err)165 assert.NilError(t, err)
166 assert.Equal(t, count, 1)166 assert.Equal(t, count, 1)
167 }167 }
modified internal/server/articles_handler.go +17 -5
@@ -46,11 +46,13 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
4646 feedURL := r.URL.Query().Get("feed")
4747 status := r.URL.Query().Get("status")
4848 searchQuery := r.URL.Query().Get("q")
49+ sortOldest := r.URL.Query().Get("sort") == "oldest"
50+ category := r.URL.Query().Get("category")
4951
5052 page := pageFromRequest(r, 50)
5153
5254 if status == "" && searchQuery == "" {
53- unreadCount, err := s.dbs.Articles.GetUnreadCount(ctx, user.DID, feedURL)
55+ unreadCount, err := s.dbs.Articles.GetUnreadCount(ctx, user.DID, feedURL, category)
5456 if err != nil {
5557 s.logger.Warn("failed to get unread count", "error", err, "did", user.DID)
5658 }
@@ -69,11 +71,11 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
6971 } else {
7072 switch status {
7173 case "unread":
72- articles, err = s.dbs.Articles.ListUnreadArticles(ctx, user.DID, feedURL, page.Limit()+1, page.Offset())
74+ articles, err = s.dbs.Articles.ListUnreadArticles(ctx, user.DID, feedURL, category, page.Limit()+1, page.Offset(), sortOldest)
7375 case "read":
74- articles, err = s.dbs.Articles.ListReadArticles(ctx, user.DID, feedURL, page.Limit()+1, page.Offset())
76+ articles, err = s.dbs.Articles.ListReadArticles(ctx, user.DID, feedURL, category, page.Limit()+1, page.Offset(), sortOldest)
7577 default:
76- articles, err = s.dbs.Articles.ListArticles(ctx, user.DID, feedURL, page.Limit()+1, page.Offset())
78+ articles, err = s.dbs.Articles.ListArticles(ctx, user.DID, feedURL, category, page.Limit()+1, page.Offset(), sortOldest)
7779 }
7880 }
7981
@@ -100,6 +102,13 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
100102 expandedView = settings.ExpandedView
101103 }
102104
105+ sortParam := ""
106+ if sortOldest {
107+ sortParam = "oldest"
108+ }
109+
110+ categories, _ := s.dbs.Articles.GetCategories(ctx, user.DID)
111+
103112 data := map[string]any{
104113 "User": user,
105114 "Articles": articles,
@@ -108,9 +117,12 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
108117 "SearchQuery": searchQuery,
109118 "Page": page,
110119 "BaseURL": "/articles",
111- "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}),
120+ "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery, "sort": sortParam, "category": category}),
112121 "Now": time.Now(),
113122 "ExpandedView": expandedView,
123+ "SortOldest": sortOldest,
124+ "Category": category,
125+ "Categories": categories,
114126 }
115127
116128 if feedURL != "" {
@@ -46,11 +46,13 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
46 feedURL := r.URL.Query().Get("feed")46 feedURL := r.URL.Query().Get("feed")
47 status := r.URL.Query().Get("status")47 status := r.URL.Query().Get("status")
48 searchQuery := r.URL.Query().Get("q")48 searchQuery := r.URL.Query().Get("q")
49+ sortOldest := r.URL.Query().Get("sort") == "oldest"
50+ category := r.URL.Query().Get("category")
49 51
50 page := pageFromRequest(r, 50)52 page := pageFromRequest(r, 50)
51 53
52 if status == "" && searchQuery == "" {54 if status == "" && searchQuery == "" {
53- unreadCount, err := s.dbs.Articles.GetUnreadCount(ctx, user.DID, feedURL)55+ unreadCount, err := s.dbs.Articles.GetUnreadCount(ctx, user.DID, feedURL, category)
54 if err != nil {56 if err != nil {
55 s.logger.Warn("failed to get unread count", "error", err, "did", user.DID)57 s.logger.Warn("failed to get unread count", "error", err, "did", user.DID)
56 }58 }
@@ -69,11 +71,11 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
69 } else {71 } else {
70 switch status {72 switch status {
71 case "unread":73 case "unread":
72- articles, err = s.dbs.Articles.ListUnreadArticles(ctx, user.DID, feedURL, page.Limit()+1, page.Offset())74+ articles, err = s.dbs.Articles.ListUnreadArticles(ctx, user.DID, feedURL, category, page.Limit()+1, page.Offset(), sortOldest)
73 case "read":75 case "read":
74- articles, err = s.dbs.Articles.ListReadArticles(ctx, user.DID, feedURL, page.Limit()+1, page.Offset())76+ articles, err = s.dbs.Articles.ListReadArticles(ctx, user.DID, feedURL, category, page.Limit()+1, page.Offset(), sortOldest)
75 default:77 default:
76- articles, err = s.dbs.Articles.ListArticles(ctx, user.DID, feedURL, page.Limit()+1, page.Offset())78+ articles, err = s.dbs.Articles.ListArticles(ctx, user.DID, feedURL, category, page.Limit()+1, page.Offset(), sortOldest)
77 }79 }
78 }80 }
79 81
@@ -100,6 +102,13 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
100 expandedView = settings.ExpandedView102 expandedView = settings.ExpandedView
101 }103 }
102 104
105+ sortParam := ""
106+ if sortOldest {
107+ sortParam = "oldest"
108+ }
109+
110+ categories, _ := s.dbs.Articles.GetCategories(ctx, user.DID)
111+
103 data := map[string]any{112 data := map[string]any{
104 "User": user,113 "User": user,
105 "Articles": articles,114 "Articles": articles,
@@ -108,9 +117,12 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
108 "SearchQuery": searchQuery,117 "SearchQuery": searchQuery,
109 "Page": page,118 "Page": page,
110 "BaseURL": "/articles",119 "BaseURL": "/articles",
111- "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}),120+ "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery, "sort": sortParam, "category": category}),
112 "Now": time.Now(),121 "Now": time.Now(),
113 "ExpandedView": expandedView,122 "ExpandedView": expandedView,
123+ "SortOldest": sortOldest,
124+ "Category": category,
125+ "Categories": categories,
114 }126 }
115 127
116 if feedURL != "" {128 if feedURL != "" {
modified internal/server/dashboard_handler.go +2 -2
@@ -34,7 +34,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
3434
3535 g.Go(func() error {
3636 var err error
37- unreadCount, err = s.dbs.Articles.GetUnreadCount(gCtx, user.DID, "")
37+ unreadCount, err = s.dbs.Articles.GetUnreadCount(gCtx, user.DID, "", "")
3838 if err != nil {
3939 s.logger.Warn("failed to get unread count", "error", err, "did", user.DID)
4040 }
@@ -52,7 +52,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
5252
5353 g.Go(func() error {
5454 var err error
55- articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 5, 0)
55+ articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", "", 5, 0, false)
5656 if err != nil {
5757 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)
5858 }
@@ -34,7 +34,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
34 34
35 g.Go(func() error {35 g.Go(func() error {
36 var err error36 var err error
37- unreadCount, err = s.dbs.Articles.GetUnreadCount(gCtx, user.DID, "")37+ unreadCount, err = s.dbs.Articles.GetUnreadCount(gCtx, user.DID, "", "")
38 if err != nil {38 if err != nil {
39 s.logger.Warn("failed to get unread count", "error", err, "did", user.DID)39 s.logger.Warn("failed to get unread count", "error", err, "did", user.DID)
40 }40 }
@@ -52,7 +52,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
52 52
53 g.Go(func() error {53 g.Go(func() error {
54 var err error54 var err error
55- articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 5, 0)55+ articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", "", 5, 0, false)
56 if err != nil {56 if err != nil {
57 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)57 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)
58 }58 }
modified internal/server/digest_handler.go +2 -2
@@ -63,7 +63,7 @@ func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx
6363
6464 g.Go(func() error {
6565 var err error
66- articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 50, 0)
66+ articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", "", 50, 0, false)
6767 return err
6868 })
6969
@@ -182,7 +182,7 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
182182 return
183183 }
184184
185- unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "")
185+ unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "", "")
186186 if unreadCount == 0 {
187187 w.WriteHeader(http.StatusNoContent)
188188 return
@@ -63,7 +63,7 @@ func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx
63 63
64 g.Go(func() error {64 g.Go(func() error {
65 var err error65 var err error
66- articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 50, 0)66+ articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", "", 50, 0, false)
67 return err67 return err
68 })68 })
69 69
@@ -182,7 +182,7 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
182 return182 return
183 }183 }
184 184
185- unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "")185+ unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "", "")
186 if unreadCount == 0 {186 if unreadCount == 0 {
187 w.WriteHeader(http.StatusNoContent)187 w.WriteHeader(http.StatusNoContent)
188 return188 return
modified internal/server/sitemap_handler.go +2 -2
@@ -8,8 +8,8 @@ import (
88 )
99
1010 type urlset struct {
11- XMLName xml.Name `xml:"urlset"`
12- Xmlns string `xml:"xmlns,attr"`
11+ XMLName xml.Name `xml:"urlset"`
12+ Xmlns string `xml:"xmlns,attr"`
1313 URLs []sitemapURL `xml:"url"`
1414 }
1515
@@ -8,8 +8,8 @@ import (
8 )8 )
9 9
10 type urlset struct {10 type urlset struct {
11- XMLName xml.Name `xml:"urlset"`11+ XMLName xml.Name `xml:"urlset"`
12- Xmlns string `xml:"xmlns,attr"`12+ Xmlns string `xml:"xmlns,attr"`
13 URLs []sitemapURL `xml:"url"`13 URLs []sitemapURL `xml:"url"`
14 }14 }
15 15
modified internal/tmpl/articles.html +82 -31
@@ -2,7 +2,7 @@
22 <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return={{if .FeedURL}}/articles?feed={{.FeedURL}}{{else}}/articles{{end}}" hx-trigger="every 30s" hx-swap="innerHTML"></div>
33
44 {{if .Feed}}
5- <div class="mb-8">
5+ <div class="mb-6">
66 <div class="flex items-center gap-2 mb-4">
77 <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">&larr; All articles</a>
88 </div>
@@ -32,46 +32,97 @@
3232 </div>
3333 {{if .Feed.Description.Valid}}<p class="text-sm text-spot-secondary mt-3 leading-relaxed">{{.Feed.Description.String}}</p>{{end}}
3434 </div>
35- {{else}}
36- <div class="flex items-center justify-between mb-2">
37- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Articles</h1>
38- <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
39- {{csrfInput .CSRFToken}}
40- <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
41- </form>
42- </div>
43- <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p>
44- {{end}}
4535
4636 <div class="flex items-center gap-3 mb-6 flex-wrap">
47- <form method="GET" action="/articles" class="flex-1" id="search-form">
48- <div class="relative">
49- <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
50- class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
51- hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
52- <svg class="w-4 h-4 text-spot-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
53- {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}}
54- {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
55- </div>
37+ <form method="GET" action="/articles" class="relative flex-1 min-w-[200px]">
38+ <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
39+ class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
40+ hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
41+ <svg class="w-4 h-4 text-spot-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
42+ <input type="hidden" name="feed" value="{{.FeedURL}}">
43+ {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
44+ {{if .SortOldest}}<input type="hidden" name="sort" value="oldest">{{end}}
5645 </form>
5746 <div class="flex items-center gap-1.5 shrink-0">
58- <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
59- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
60- {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
61- All
62- </a>
63- <a href="/articles?status=unread{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
64- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
65- {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
66- Unread
67- </a>
68- <a href="/articles?status=read{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
47+ <a href="/articles?status=all&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
48+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
49+ {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
50+ All
51+ </a>
52+ <a href="/articles?status=unread&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
53+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
54+ {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
55+ Unread
56+ </a>
57+ <a href="/articles?status=read&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
6958 class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
7059 {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
7160 Read
7261 </a>
62+ <a href="/articles?{{if not .SortOldest}}sort=oldest{{end}}&feed={{.FeedURL}}{{if .Status}}&status={{.Status}}{{end}}"
63+ class="px-2.5 py-1.5 rounded-pill text-xs transition
64+ {{if .SortOldest}}bg-spot-active-pill-bg text-spot-active-pill-text font-bold{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"
65+ title="{{if .SortOldest}}Newest first{{else}}Oldest first{{end}}">
66+ {{if .SortOldest}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>{{else}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>{{end}}
67+ </a>
7368 </div>
7469 </div>
70+ {{else}}
71+ <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
72+ <div class="flex items-center gap-3">
73+ <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Articles</h1>
74+ <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
75+ {{csrfInput .CSRFToken}}
76+ <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-3 py-1 text-[10px] font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
77+ </form>
78+ </div>
79+ <div class="flex items-center gap-1.5">
80+ <a href="/articles?status=all{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
81+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
82+ {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
83+ All
84+ </a>
85+ <a href="/articles?status=unread{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
86+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
87+ {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
88+ Unread
89+ </a>
90+ <a href="/articles?status=read{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
91+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
92+ {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
93+ Read
94+ </a>
95+ </div>
96+ </div>
97+
98+ <div class="flex items-center gap-3 mb-6 flex-wrap">
99+ <div class="flex items-center gap-1.5">
100+ <a href="/articles?{{if not .SortOldest}}sort=oldest{{end}}{{if .Status}}&status={{.Status}}{{end}}"
101+ class="px-2.5 py-1.5 rounded-pill text-xs transition
102+ {{if .SortOldest}}bg-spot-active-pill-bg text-spot-active-pill-text font-bold{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"
103+ title="{{if .SortOldest}}Newest first{{else}}Oldest first{{end}}">
104+ {{if .SortOldest}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>{{else}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>{{end}}
105+ </a>
106+ {{if .Categories}}
107+ <span class="text-spot-divider">|</span>
108+ <a href="/articles?{{if .Status}}status={{.Status}}&{{end}}{{if .SortOldest}}sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a>
109+ {{range .Categories}}
110+ <a href="/articles?{{if $.Status}}status={{$.Status}}&{{end}}category={{.}}{{if $.SortOldest}}&sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a>
111+ {{end}}
112+ <a href="/articles?{{if .Status}}status={{.Status}}&{{end}}category=__none__{{if .SortOldest}}&sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if eq .Category "__none__"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">Uncategorized</a>
113+ {{end}}
114+ </div>
115+ <form method="GET" action="/articles" class="relative flex-1 min-w-[180px]">
116+ <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
117+ class="w-full bg-spot-hover text-spot-text rounded-pill pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
118+ hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
119+ <svg class="w-3 h-3 text-spot-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
120+ {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
121+ {{if .SortOldest}}<input type="hidden" name="sort" value="oldest">{{end}}
122+ {{if .Category}}<input type="hidden" name="category" value="{{.Category}}">{{end}}
123+ </form>
124+ </div>
125+ {{end}}
75126
76127 <div id="article-list" class="space-y-3">
77128 {{template "articles-content.html" .}}
@@ -2,7 +2,7 @@
2 <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return={{if .FeedURL}}/articles?feed={{.FeedURL}}{{else}}/articles{{end}}" hx-trigger="every 30s" hx-swap="innerHTML"></div>2 <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return={{if .FeedURL}}/articles?feed={{.FeedURL}}{{else}}/articles{{end}}" hx-trigger="every 30s" hx-swap="innerHTML"></div>
3 3
4 {{if .Feed}}4 {{if .Feed}}
5- <div class="mb-8">5+ <div class="mb-6">
6 <div class="flex items-center gap-2 mb-4">6 <div class="flex items-center gap-2 mb-4">
7 <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">&larr; All articles</a>7 <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">&larr; All articles</a>
8 </div>8 </div>
@@ -32,46 +32,97 @@
32 </div>32 </div>
33 {{if .Feed.Description.Valid}}<p class="text-sm text-spot-secondary mt-3 leading-relaxed">{{.Feed.Description.String}}</p>{{end}}33 {{if .Feed.Description.Valid}}<p class="text-sm text-spot-secondary mt-3 leading-relaxed">{{.Feed.Description.String}}</p>{{end}}
34 </div>34 </div>
35- {{else}}
36- <div class="flex items-center justify-between mb-2">
37- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Articles</h1>
38- <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
39- {{csrfInput .CSRFToken}}
40- <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
41- </form>
42- </div>
43- <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p>
44- {{end}}
45 35
46 <div class="flex items-center gap-3 mb-6 flex-wrap">36 <div class="flex items-center gap-3 mb-6 flex-wrap">
47- <form method="GET" action="/articles" class="flex-1" id="search-form">37+ <form method="GET" action="/articles" class="relative flex-1 min-w-[200px]">
48- <div class="relative">38+ <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
49- <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."39+ class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
50- class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"40+ hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
51- hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">41+ <svg class="w-4 h-4 text-spot-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
52- <svg class="w-4 h-4 text-spot-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>42+ <input type="hidden" name="feed" value="{{.FeedURL}}">
53- {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}}43+ {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
54- {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}44+ {{if .SortOldest}}<input type="hidden" name="sort" value="oldest">{{end}}
55- </div>
56 </form>45 </form>
57 <div class="flex items-center gap-1.5 shrink-0">46 <div class="flex items-center gap-1.5 shrink-0">
58- <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"47+ <a href="/articles?status=all&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
59- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition48+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
60- {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">49+ {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
61- All50+ All
62- </a>51+ </a>
63- <a href="/articles?status=unread{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"52+ <a href="/articles?status=unread&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
64- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition53+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
65- {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">54+ {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
66- Unread55+ Unread
67- </a>56+ </a>
68- <a href="/articles?status=read{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"57+ <a href="/articles?status=read&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
69 class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition58 class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
70 {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">59 {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
71 Read60 Read
72 </a>61 </a>
62+ <a href="/articles?{{if not .SortOldest}}sort=oldest{{end}}&feed={{.FeedURL}}{{if .Status}}&status={{.Status}}{{end}}"
63+ class="px-2.5 py-1.5 rounded-pill text-xs transition
64+ {{if .SortOldest}}bg-spot-active-pill-bg text-spot-active-pill-text font-bold{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"
65+ title="{{if .SortOldest}}Newest first{{else}}Oldest first{{end}}">
66+ {{if .SortOldest}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>{{else}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>{{end}}
67+ </a>
73 </div>68 </div>
74 </div>69 </div>
70+ {{else}}
71+ <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
72+ <div class="flex items-center gap-3">
73+ <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Articles</h1>
74+ <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
75+ {{csrfInput .CSRFToken}}
76+ <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-3 py-1 text-[10px] font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
77+ </form>
78+ </div>
79+ <div class="flex items-center gap-1.5">
80+ <a href="/articles?status=all{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
81+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
82+ {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
83+ All
84+ </a>
85+ <a href="/articles?status=unread{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
86+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
87+ {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
88+ Unread
89+ </a>
90+ <a href="/articles?status=read{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
91+ class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
92+ {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
93+ Read
94+ </a>
95+ </div>
96+ </div>
97+
98+ <div class="flex items-center gap-3 mb-6 flex-wrap">
99+ <div class="flex items-center gap-1.5">
100+ <a href="/articles?{{if not .SortOldest}}sort=oldest{{end}}{{if .Status}}&status={{.Status}}{{end}}"
101+ class="px-2.5 py-1.5 rounded-pill text-xs transition
102+ {{if .SortOldest}}bg-spot-active-pill-bg text-spot-active-pill-text font-bold{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"
103+ title="{{if .SortOldest}}Newest first{{else}}Oldest first{{end}}">
104+ {{if .SortOldest}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>{{else}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>{{end}}
105+ </a>
106+ {{if .Categories}}
107+ <span class="text-spot-divider">|</span>
108+ <a href="/articles?{{if .Status}}status={{.Status}}&{{end}}{{if .SortOldest}}sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a>
109+ {{range .Categories}}
110+ <a href="/articles?{{if $.Status}}status={{$.Status}}&{{end}}category={{.}}{{if $.SortOldest}}&sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a>
111+ {{end}}
112+ <a href="/articles?{{if .Status}}status={{.Status}}&{{end}}category=__none__{{if .SortOldest}}&sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if eq .Category "__none__"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">Uncategorized</a>
113+ {{end}}
114+ </div>
115+ <form method="GET" action="/articles" class="relative flex-1 min-w-[180px]">
116+ <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
117+ class="w-full bg-spot-hover text-spot-text rounded-pill pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
118+ hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
119+ <svg class="w-3 h-3 text-spot-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
120+ {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
121+ {{if .SortOldest}}<input type="hidden" name="sort" value="oldest">{{end}}
122+ {{if .Category}}<input type="hidden" name="category" value="{{.Category}}">{{end}}
123+ </form>
124+ </div>
125+ {{end}}
75 126
76 <div id="article-list" class="space-y-3">127 <div id="article-list" class="space-y-3">
77 {{template "articles-content.html" .}}128 {{template "articles-content.html" .}}
modified internal/tmpl/base.html +6 -0
@@ -150,6 +150,12 @@
150150 <a href="/profile/{{.User.DID}}" class="shrink-0 ml-1" title="@{{.User.Handle}}">
151151 {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full ring-2 ring-spot-divider">{{end}}
152152 </a>
153+ <form method="POST" action="/auth/logout" class="shrink-0">
154+ {{csrfInput .CSRFToken}}
155+ <button type="submit" class="text-spot-muted hover:text-spot-red p-1.5 rounded-md hover:bg-spot-hover-50 transition" title="Sign out">
156+ <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
157+ </button>
158+ </form>
153159 {{else}}
154160 <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-3 py-1 text-xs font-bold uppercase tracking-button hover:brightness-110 transition shrink-0 ml-1">Sign in</a>
155161 {{end}}
@@ -150,6 +150,12 @@
150 <a href="/profile/{{.User.DID}}" class="shrink-0 ml-1" title="@{{.User.Handle}}">150 <a href="/profile/{{.User.DID}}" class="shrink-0 ml-1" title="@{{.User.Handle}}">
151 {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full ring-2 ring-spot-divider">{{end}}151 {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full ring-2 ring-spot-divider">{{end}}
152 </a>152 </a>
153+ <form method="POST" action="/auth/logout" class="shrink-0">
154+ {{csrfInput .CSRFToken}}
155+ <button type="submit" class="text-spot-muted hover:text-spot-red p-1.5 rounded-md hover:bg-spot-hover-50 transition" title="Sign out">
156+ <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
157+ </button>
158+ </form>
153 {{else}}159 {{else}}
154 <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-3 py-1 text-xs font-bold uppercase tracking-button hover:brightness-110 transition shrink-0 ml-1">Sign in</a>160 <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-3 py-1 text-xs font-bold uppercase tracking-button hover:brightness-110 transition shrink-0 ml-1">Sign in</a>
155 {{end}}161 {{end}}