Add category filtering and sort ordering to articles listingUnverified
4a3040f parent: b75bc69 modified
internal/db/article.go +63 -16 | @@ -11,7 +11,8 @@ import ( | ||
| 11 | 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 | 17 | type ArticleStore struct { |
| 17 | 18 | db *DB |
| @@ -110,9 +111,16 @@ func (s *ArticleStore) GetArticle(ctx context.Context, id int64) (*Article, erro | ||
| 110 | 111 | return a, nil |
| 111 | 112 | } |
| 112 | 113 | |
| 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 | + ) | |
| 116 | 124 | |
| 117 | 125 | if feedURL != "" { |
| 118 | 126 | query = ` |
| @@ -141,10 +149,14 @@ func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string | ||
| 141 | 149 | WHERE 1=1 |
| 142 | 150 | ` |
| 143 | 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 last | |
| 147 | - query += articlesOrderBy | |
| 155 | + orderBy := articlesOrderByDesc | |
| 156 | + if sortOldest { | |
| 157 | + orderBy = articlesOrderByAsc | |
| 158 | + } | |
| 159 | + query += orderBy | |
| 148 | 160 | args = append(args, limit, offset) |
| 149 | 161 | |
| 150 | 162 | rows, err := s.db.QueryContext(ctx, query, args...) |
| @@ -166,7 +178,26 @@ func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string | ||
| 166 | 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 | 201 | var query string |
| 171 | 202 | var args []any |
| 172 | 203 | |
| @@ -197,10 +228,14 @@ func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL | ||
| 197 | 228 | WHERE (r.is_read = 0 OR r.is_read IS NULL) |
| 198 | 229 | ` |
| 199 | 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 last | |
| 203 | - query += articlesOrderBy | |
| 234 | + orderBy := articlesOrderByDesc | |
| 235 | + if sortOldest { | |
| 236 | + orderBy = articlesOrderByAsc | |
| 237 | + } | |
| 238 | + query += orderBy | |
| 204 | 239 | args = append(args, limit, offset) |
| 205 | 240 | |
| 206 | 241 | rows, err := s.db.QueryContext(ctx, query, args...) |
| @@ -222,7 +257,12 @@ func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL | ||
| 222 | 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 | 266 | var query string |
| 227 | 267 | var args []any |
| 228 | 268 | |
| @@ -253,10 +293,14 @@ func (s *ArticleStore) ListReadArticles(ctx context.Context, userDID, feedURL st | ||
| 253 | 293 | WHERE r.is_read = 1 |
| 254 | 294 | ` |
| 255 | 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 last | |
| 259 | - query += articlesOrderBy | |
| 299 | + orderBy := articlesOrderByDesc | |
| 300 | + if sortOldest { | |
| 301 | + orderBy = articlesOrderByAsc | |
| 302 | + } | |
| 303 | + query += orderBy | |
| 260 | 304 | args = append(args, limit, offset) |
| 261 | 305 | |
| 262 | 306 | rows, err := s.db.QueryContext(ctx, query, args...) |
| @@ -363,7 +407,7 @@ func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, article | ||
| 363 | 407 | 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 | 411 | var count int |
| 368 | 412 | if feedURL != "" { |
| 369 | 413 | err := s.db.QueryRowContext(ctx, ` |
| @@ -374,13 +418,16 @@ func (s *ArticleStore) GetUnreadCount(ctx context.Context, userDID, feedURL stri | ||
| 374 | 418 | `, userDID, feedURL).Scan(&count) |
| 375 | 419 | return count, err |
| 376 | 420 | } |
| 377 | - err := s.db.QueryRowContext(ctx, ` | |
| 421 | + query := ` | |
| 378 | 422 | SELECT COUNT(*) |
| 379 | 423 | FROM articles.articles a |
| 380 | 424 | JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? |
| 381 | 425 | 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) | |
| 384 | 431 | return count, err |
| 385 | 432 | } |
| 386 | 433 | |
| @@ -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 *DB | 18 | db *DB |
| @@ -110,9 +111,16 @@ func (s *ArticleStore) GetArticle(ctx context.Context, id int64) (*Article, erro | |||
| 110 | return a, nil | 111 | 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 string | 115 | + ctx context.Context, |
| 115 | - var args []any | 116 | + 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=1 | 149 | 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 last | 155 | + orderBy := articlesOrderByDesc |
| 147 | - query += articlesOrderBy | 156 | + 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 string | 201 | var query string |
| 171 | var args []any | 202 | 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 last | 234 | + orderBy := articlesOrderByDesc |
| 203 | - query += articlesOrderBy | 235 | + 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 string | 266 | var query string |
| 227 | var args []any | 267 | var args []any |
| 228 | 268 | ||
| @@ -253,10 +293,14 @@ func (s *ArticleStore) ListReadArticles(ctx context.Context, userDID, feedURL st | |||
| 253 | WHERE r.is_read = 1 | 293 | 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 last | 299 | + orderBy := articlesOrderByDesc |
| 259 | - query += articlesOrderBy | 300 | + 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, nil | 407 | 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 int | 411 | 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, err | 419 | return count, err |
| 376 | } | 420 | } |
| 377 | - err := s.db.QueryRowContext(ctx, ` | 421 | + query := ` |
| 378 | SELECT COUNT(*) | 422 | SELECT COUNT(*) |
| 379 | FROM articles.articles a | 423 | 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.id | 425 | 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 | 426 | + 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, err | 431 | 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) { | ||
| 63 | 63 | dbs := setupTestDB(t) |
| 64 | 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 | 67 | assert.NilError(t, err) |
| 68 | 68 | assert.Equal(t, len(results), 1) |
| 69 | 69 | assert.Equal(t, results[0].ID, readID) |
| @@ -77,7 +77,7 @@ func TestListReadArticles_ExcludesUnread(t *testing.T) { | ||
| 77 | 77 | dbs := setupTestDB(t) |
| 78 | 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 | 81 | assert.NilError(t, err) |
| 82 | 82 | for _, a := range results { |
| 83 | 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 | 89 | dbs := setupTestDB(t) |
| 90 | 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 | 93 | assert.NilError(t, err) |
| 94 | 94 | assert.Equal(t, len(results), 1) |
| 95 | 95 | assert.Equal(t, results[0].ID, unreadID) |
| @@ -103,7 +103,7 @@ func TestListArticles_ReturnsAll(t *testing.T) { | ||
| 103 | 103 | dbs := setupTestDB(t) |
| 104 | 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 | 107 | assert.NilError(t, err) |
| 108 | 108 | assert.Equal(t, len(results), 2) |
| 109 | 109 | } |
| @@ -133,7 +133,7 @@ func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) { | ||
| 133 | 133 | dbs := setupTestDB(t) |
| 134 | 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 | 137 | assert.NilError(t, err) |
| 138 | 138 | assert.Equal(t, len(results), 1) |
| 139 | 139 | } |
| @@ -143,11 +143,11 @@ func TestListReadArticles_WithFeedURLFilter(t *testing.T) { | ||
| 143 | 143 | dbs := setupTestDB(t) |
| 144 | 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 | 147 | assert.NilError(t, err) |
| 148 | 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 | 151 | assert.NilError(t, err) |
| 152 | 152 | assert.Equal(t, len(results), 0) |
| 153 | 153 | } |
| @@ -157,11 +157,11 @@ func TestGetUnreadCount(t *testing.T) { | ||
| 157 | 157 | dbs := setupTestDB(t) |
| 158 | 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 | 161 | assert.NilError(t, err) |
| 162 | 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 | 165 | assert.NilError(t, err) |
| 166 | 166 | assert.Equal(t, count, 1) |
| 167 | 167 | } |
| @@ -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) { | ||
| 46 | 46 | feedURL := r.URL.Query().Get("feed") |
| 47 | 47 | status := r.URL.Query().Get("status") |
| 48 | 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 | 52 | page := pageFromRequest(r, 50) |
| 51 | 53 | |
| 52 | 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 | 56 | if err != nil { |
| 55 | 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 | 71 | } else { |
| 70 | 72 | switch status { |
| 71 | 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 | 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 | 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 | 102 | 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 | 112 | data := map[string]any{ |
| 104 | 113 | "User": user, |
| 105 | 114 | "Articles": articles, |
| @@ -108,9 +117,12 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | ||
| 108 | 117 | "SearchQuery": searchQuery, |
| 109 | 118 | "Page": page, |
| 110 | 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 | 121 | "Now": time.Now(), |
| 113 | 122 | "ExpandedView": expandedView, |
| 123 | + "SortOldest": sortOldest, | |
| 124 | + "Category": category, | |
| 125 | + "Categories": categories, | |
| 114 | 126 | } |
| 115 | 127 | |
| 116 | 128 | 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.ExpandedView | 102 | 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) { | ||
| 34 | 34 | |
| 35 | 35 | g.Go(func() error { |
| 36 | 36 | var err error |
| 37 | - unreadCount, err = s.dbs.Articles.GetUnreadCount(gCtx, user.DID, "") | |
| 37 | + unreadCount, err = s.dbs.Articles.GetUnreadCount(gCtx, user.DID, "", "") | |
| 38 | 38 | if err != nil { |
| 39 | 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 | 53 | g.Go(func() error { |
| 54 | 54 | 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 | 56 | if err != nil { |
| 57 | 57 | s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID) |
| 58 | 58 | } |
| @@ -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 error | 36 | 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 error | 54 | 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 | ||
| 63 | 63 | |
| 64 | 64 | g.Go(func() error { |
| 65 | 65 | 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 | 67 | return err |
| 68 | 68 | }) |
| 69 | 69 | |
| @@ -182,7 +182,7 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) { | ||
| 182 | 182 | return |
| 183 | 183 | } |
| 184 | 184 | |
| 185 | - unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "") | |
| 185 | + unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "", "") | |
| 186 | 186 | if unreadCount == 0 { |
| 187 | 187 | w.WriteHeader(http.StatusNoContent) |
| 188 | 188 | 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 error | 65 | 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 err | 67 | return err |
| 68 | }) | 68 | }) |
| 69 | 69 | ||
| @@ -182,7 +182,7 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) { | |||
| 182 | return | 182 | 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 | return | 188 | return |
modified
internal/server/sitemap_handler.go +2 -2 | @@ -8,8 +8,8 @@ import ( | ||
| 8 | 8 | ) |
| 9 | 9 | |
| 10 | 10 | 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"` | |
| 13 | 13 | URLs []sitemapURL `xml:"url"` |
| 14 | 14 | } |
| 15 | 15 | |
| @@ -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 @@ | ||
| 2 | 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 | 4 | {{if .Feed}} |
| 5 | - <div class="mb-8"> | |
| 5 | + <div class="mb-6"> | |
| 6 | 6 | <div class="flex items-center gap-2 mb-4"> |
| 7 | 7 | <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">← All articles</a> |
| 8 | 8 | </div> |
| @@ -32,46 +32,97 @@ | ||
| 32 | 32 | </div> |
| 33 | 33 | {{if .Feed.Description.Valid}}<p class="text-sm text-spot-secondary mt-3 leading-relaxed">{{.Feed.Description.String}}</p>{{end}} |
| 34 | 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 | 36 | <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}} | |
| 56 | 45 | </form> |
| 57 | 46 | <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}}" | |
| 69 | 58 | class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition |
| 70 | 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 | 60 | Read |
| 72 | 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 | 68 | </div> |
| 74 | 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 | 127 | <div id="article-list" class="space-y-3"> |
| 77 | 128 | {{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">← All articles</a> | 7 | <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">← 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 transition | 48 | + 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 | - All | 50 | + 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 transition | 53 | + 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 | - Unread | 55 | + 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 transition | 58 | 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 | Read | 60 | 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 @@ | ||
| 150 | 150 | <a href="/profile/{{.User.DID}}" class="shrink-0 ml-1" title="@{{.User.Handle}}"> |
| 151 | 151 | {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full ring-2 ring-spot-divider">{{end}} |
| 152 | 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 | 159 | {{else}} |
| 154 | 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 | 161 | {{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}} |