Add full-text search for articles with SQLite FTS5Unverified
34e8a2d parent: ef3d40a modified
Makefile +3 -3 | @@ -19,11 +19,11 @@ lex-parse: | ||
| 19 | 19 | |
| 20 | 20 | .PHONY: dev |
| 21 | 21 | dev: css |
| 22 | - @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run . | |
| 22 | + @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run -tags fts5 . | |
| 23 | 23 | |
| 24 | 24 | .PHONY: build |
| 25 | 25 | build: css |
| 26 | - go build -o glean . | |
| 26 | + go build -tags fts5 -o glean . | |
| 27 | 27 | |
| 28 | 28 | .PHONY: css |
| 29 | 29 | css: |
| @@ -35,7 +35,7 @@ css-watch: | ||
| 35 | 35 | |
| 36 | 36 | .PHONY: test |
| 37 | 37 | test: |
| 38 | - go test ./... | |
| 38 | + go test -tags fts5 ./... | |
| 39 | 39 | |
| 40 | 40 | .PHONY: clean |
| 41 | 41 | clean: |
| @@ -19,11 +19,11 @@ lex-parse: | |||
| 19 | 19 | ||
| 20 | .PHONY: dev | 20 | .PHONY: dev |
| 21 | dev: css | 21 | dev: css |
| 22 | - @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run . | 22 | + @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run -tags fts5 . |
| 23 | 23 | ||
| 24 | .PHONY: build | 24 | .PHONY: build |
| 25 | build: css | 25 | build: css |
| 26 | - go build -o glean . | 26 | + go build -tags fts5 -o glean . |
| 27 | 27 | ||
| 28 | .PHONY: css | 28 | .PHONY: css |
| 29 | css: | 29 | css: |
| @@ -35,7 +35,7 @@ css-watch: | |||
| 35 | 35 | ||
| 36 | .PHONY: test | 36 | .PHONY: test |
| 37 | test: | 37 | test: |
| 38 | - go test ./... | 38 | + go test -tags fts5 ./... |
| 39 | 39 | ||
| 40 | .PHONY: clean | 40 | .PHONY: clean |
| 41 | clean: | 41 | clean: |
modified
internal/db/article.go +37 -0 | @@ -3,6 +3,7 @@ package db | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | + "strings" | |
| 6 | 7 | "time" |
| 7 | 8 | ) |
| 8 | 9 | |
| @@ -293,3 +294,39 @@ func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.T | ||
| 293 | 294 | `, userDID, since).Scan(&count) |
| 294 | 295 | return count, err |
| 295 | 296 | } |
| 297 | + | |
| 298 | +func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) { | |
| 299 | + if strings.TrimSpace(query) == "" { | |
| 300 | + return nil, nil | |
| 301 | + } | |
| 302 | + | |
| 303 | + rows, err := db.QueryContext(ctx, ` | |
| 304 | + SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, | |
| 305 | + a.published, a.updated, a.fetched_at, | |
| 306 | + COALESCE(r.is_read, 0) | |
| 307 | + FROM articles_fts ft | |
| 308 | + JOIN articles a ON a.id = ft.rowid | |
| 309 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | |
| 310 | + LEFT JOIN feeds f ON a.feed_url = f.feed_url | |
| 311 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | |
| 312 | + WHERE articles_fts MATCH ? | |
| 313 | + ORDER BY ft.rank | |
| 314 | + LIMIT ? OFFSET ? | |
| 315 | + `, userDID, userDID, query, limit, offset) | |
| 316 | + if err != nil { | |
| 317 | + return nil, err | |
| 318 | + } | |
| 319 | + defer rows.Close() | |
| 320 | + | |
| 321 | + var articles []*Article | |
| 322 | + for rows.Next() { | |
| 323 | + a := &Article{} | |
| 324 | + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.FeedFaviconURL, &a.GUID, &a.Title, &a.URL, &a.Author, | |
| 325 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt, | |
| 326 | + &a.IsRead); err != nil { | |
| 327 | + return nil, err | |
| 328 | + } | |
| 329 | + articles = append(articles, a) | |
| 330 | + } | |
| 331 | + return articles, rows.Err() | |
| 332 | +} | |
| @@ -3,6 +3,7 @@ package db | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | + "strings" | ||
| 6 | "time" | 7 | "time" |
| 7 | ) | 8 | ) |
| 8 | 9 | ||
| @@ -293,3 +294,39 @@ func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.T | |||
| 293 | `, userDID, since).Scan(&count) | 294 | `, userDID, since).Scan(&count) |
| 294 | return count, err | 295 | return count, err |
| 295 | } | 296 | } |
| 297 | + | ||
| 298 | +func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) { | ||
| 299 | + if strings.TrimSpace(query) == "" { | ||
| 300 | + return nil, nil | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + rows, err := db.QueryContext(ctx, ` | ||
| 304 | + SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, | ||
| 305 | + a.published, a.updated, a.fetched_at, | ||
| 306 | + COALESCE(r.is_read, 0) | ||
| 307 | + FROM articles_fts ft | ||
| 308 | + JOIN articles a ON a.id = ft.rowid | ||
| 309 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | ||
| 310 | + LEFT JOIN feeds f ON a.feed_url = f.feed_url | ||
| 311 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | ||
| 312 | + WHERE articles_fts MATCH ? | ||
| 313 | + ORDER BY ft.rank | ||
| 314 | + LIMIT ? OFFSET ? | ||
| 315 | + `, userDID, userDID, query, limit, offset) | ||
| 316 | + if err != nil { | ||
| 317 | + return nil, err | ||
| 318 | + } | ||
| 319 | + defer rows.Close() | ||
| 320 | + | ||
| 321 | + var articles []*Article | ||
| 322 | + for rows.Next() { | ||
| 323 | + a := &Article{} | ||
| 324 | + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.FeedFaviconURL, &a.GUID, &a.Title, &a.URL, &a.Author, | ||
| 325 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt, | ||
| 326 | + &a.IsRead); err != nil { | ||
| 327 | + return nil, err | ||
| 328 | + } | ||
| 329 | + articles = append(articles, a) | ||
| 330 | + } | ||
| 331 | + return articles, rows.Err() | ||
| 332 | +} | ||
modified
internal/db/article_test.go +139 -0 | @@ -185,3 +185,142 @@ func TestGetArticle_IncludesFullContent(t *testing.T) { | ||
| 185 | 185 | assert.NilError(t, err) |
| 186 | 186 | assert.Assert(t, !article.FullContent.Valid) |
| 187 | 187 | } |
| 188 | + | |
| 189 | +func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, feedURL string) { | |
| 190 | + t.Helper() | |
| 191 | + | |
| 192 | + userDID = "did:test:searcher" | |
| 193 | + feedURL = "https://search.example.com/feed.xml" | |
| 194 | + | |
| 195 | + _, err := database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "searcher") | |
| 196 | + assert.NilError(t, err) | |
| 197 | + | |
| 198 | + _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Tech Blog") | |
| 199 | + assert.NilError(t, err) | |
| 200 | + | |
| 201 | + _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL) | |
| 202 | + assert.NilError(t, err) | |
| 203 | + | |
| 204 | + articles := []struct { | |
| 205 | + guid, title, summary, content string | |
| 206 | + }{ | |
| 207 | + {"g1", "Go Programming Basics", "Learn Go fundamentals", "Go is a statically typed compiled language"}, | |
| 208 | + {"g2", "Rust Memory Safety", "Understanding ownership in Rust", "Rust provides memory safety without garbage collection"}, | |
| 209 | + {"g3", "Python Data Science", "NumPy and Pandas tutorial", "Python is popular for data analysis"}, | |
| 210 | + } | |
| 211 | + for _, a := range articles { | |
| 212 | + _, err := database.ExecContext(ctx, ` | |
| 213 | + INSERT INTO articles (feed_url, guid, title, summary, content) VALUES (?, ?, ?, ?, ?) | |
| 214 | + `, feedURL, a.guid, a.title, a.summary, a.content) | |
| 215 | + assert.NilError(t, err) | |
| 216 | + } | |
| 217 | + | |
| 218 | + return userDID, feedURL | |
| 219 | +} | |
| 220 | + | |
| 221 | +func TestSearchArticles_FindsByTitle(t *testing.T) { | |
| 222 | + ctx := context.Background() | |
| 223 | + db := setupTestDB(t) | |
| 224 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 225 | + | |
| 226 | + results, err := db.SearchArticles(ctx, userDID, "Go Programming", 10, 0) | |
| 227 | + assert.NilError(t, err) | |
| 228 | + assert.Equal(t, len(results), 1) | |
| 229 | + assert.Equal(t, results[0].Title, "Go Programming Basics") | |
| 230 | +} | |
| 231 | + | |
| 232 | +func TestSearchArticles_FindsBySummary(t *testing.T) { | |
| 233 | + ctx := context.Background() | |
| 234 | + db := setupTestDB(t) | |
| 235 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 236 | + | |
| 237 | + results, err := db.SearchArticles(ctx, userDID, "ownership", 10, 0) | |
| 238 | + assert.NilError(t, err) | |
| 239 | + assert.Equal(t, len(results), 1) | |
| 240 | + assert.Equal(t, results[0].Title, "Rust Memory Safety") | |
| 241 | +} | |
| 242 | + | |
| 243 | +func TestSearchArticles_FindsByContent(t *testing.T) { | |
| 244 | + ctx := context.Background() | |
| 245 | + db := setupTestDB(t) | |
| 246 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 247 | + | |
| 248 | + results, err := db.SearchArticles(ctx, userDID, "garbage collection", 10, 0) | |
| 249 | + assert.NilError(t, err) | |
| 250 | + assert.Equal(t, len(results), 1) | |
| 251 | + assert.Equal(t, results[0].Title, "Rust Memory Safety") | |
| 252 | +} | |
| 253 | + | |
| 254 | +func TestSearchArticles_NoResults(t *testing.T) { | |
| 255 | + ctx := context.Background() | |
| 256 | + db := setupTestDB(t) | |
| 257 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 258 | + | |
| 259 | + results, err := db.SearchArticles(ctx, userDID, "nonexistent_xyz", 10, 0) | |
| 260 | + assert.NilError(t, err) | |
| 261 | + assert.Equal(t, len(results), 0) | |
| 262 | +} | |
| 263 | + | |
| 264 | +func TestSearchArticles_MultipleMatches(t *testing.T) { | |
| 265 | + ctx := context.Background() | |
| 266 | + db := setupTestDB(t) | |
| 267 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 268 | + | |
| 269 | + results, err := db.SearchArticles(ctx, userDID, "Python", 10, 0) | |
| 270 | + assert.NilError(t, err) | |
| 271 | + assert.Assert(t, len(results) >= 1) | |
| 272 | + | |
| 273 | + var found bool | |
| 274 | + for _, a := range results { | |
| 275 | + if a.Title == "Python Data Science" { | |
| 276 | + found = true | |
| 277 | + break | |
| 278 | + } | |
| 279 | + } | |
| 280 | + assert.Assert(t, found, "expected to find Python Data Science article") | |
| 281 | +} | |
| 282 | + | |
| 283 | +func TestSearchArticles_ScopedToSubscriptions(t *testing.T) { | |
| 284 | + ctx := context.Background() | |
| 285 | + db := setupTestDB(t) | |
| 286 | + userDID, feedURL := seedSearchData(t, ctx, db) | |
| 287 | + | |
| 288 | + otherFeed := "https://other.example.com/feed.xml" | |
| 289 | + _, err := db.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, otherFeed, "Other Feed") | |
| 290 | + assert.NilError(t, err) | |
| 291 | + | |
| 292 | + _, err = db.ExecContext(ctx, ` | |
| 293 | + INSERT INTO articles (feed_url, guid, title) VALUES (?, ?, ?) | |
| 294 | + `, otherFeed, "other-1", "Go Concurrency Tips") | |
| 295 | + assert.NilError(t, err) | |
| 296 | + | |
| 297 | + results, err := db.SearchArticles(ctx, userDID, "Go", 10, 0) | |
| 298 | + assert.NilError(t, err) | |
| 299 | + for _, a := range results { | |
| 300 | + assert.Equal(t, a.FeedURL, feedURL) | |
| 301 | + } | |
| 302 | +} | |
| 303 | + | |
| 304 | +func TestSearchArticles_Pagination(t *testing.T) { | |
| 305 | + ctx := context.Background() | |
| 306 | + db := setupTestDB(t) | |
| 307 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 308 | + | |
| 309 | + results, err := db.SearchArticles(ctx, userDID, "Go", 1, 0) | |
| 310 | + assert.NilError(t, err) | |
| 311 | + assert.Equal(t, len(results), 1) | |
| 312 | + | |
| 313 | + results2, err := db.SearchArticles(ctx, userDID, "Go", 1, 1) | |
| 314 | + assert.NilError(t, err) | |
| 315 | + assert.Assert(t, len(results2) == 0 || results2[0].ID != results[0].ID) | |
| 316 | +} | |
| 317 | + | |
| 318 | +func TestSearchArticles_EmptyQuery(t *testing.T) { | |
| 319 | + ctx := context.Background() | |
| 320 | + db := setupTestDB(t) | |
| 321 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 322 | + | |
| 323 | + results, err := db.SearchArticles(ctx, userDID, "", 10, 0) | |
| 324 | + assert.NilError(t, err) | |
| 325 | + assert.Equal(t, len(results), 0) | |
| 326 | +} | |
| @@ -185,3 +185,142 @@ func TestGetArticle_IncludesFullContent(t *testing.T) { | |||
| 185 | assert.NilError(t, err) | 185 | assert.NilError(t, err) |
| 186 | assert.Assert(t, !article.FullContent.Valid) | 186 | assert.Assert(t, !article.FullContent.Valid) |
| 187 | } | 187 | } |
| 188 | + | ||
| 189 | +func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, feedURL string) { | ||
| 190 | + t.Helper() | ||
| 191 | + | ||
| 192 | + userDID = "did:test:searcher" | ||
| 193 | + feedURL = "https://search.example.com/feed.xml" | ||
| 194 | + | ||
| 195 | + _, err := database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "searcher") | ||
| 196 | + assert.NilError(t, err) | ||
| 197 | + | ||
| 198 | + _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Tech Blog") | ||
| 199 | + assert.NilError(t, err) | ||
| 200 | + | ||
| 201 | + _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL) | ||
| 202 | + assert.NilError(t, err) | ||
| 203 | + | ||
| 204 | + articles := []struct { | ||
| 205 | + guid, title, summary, content string | ||
| 206 | + }{ | ||
| 207 | + {"g1", "Go Programming Basics", "Learn Go fundamentals", "Go is a statically typed compiled language"}, | ||
| 208 | + {"g2", "Rust Memory Safety", "Understanding ownership in Rust", "Rust provides memory safety without garbage collection"}, | ||
| 209 | + {"g3", "Python Data Science", "NumPy and Pandas tutorial", "Python is popular for data analysis"}, | ||
| 210 | + } | ||
| 211 | + for _, a := range articles { | ||
| 212 | + _, err := database.ExecContext(ctx, ` | ||
| 213 | + INSERT INTO articles (feed_url, guid, title, summary, content) VALUES (?, ?, ?, ?, ?) | ||
| 214 | + `, feedURL, a.guid, a.title, a.summary, a.content) | ||
| 215 | + assert.NilError(t, err) | ||
| 216 | + } | ||
| 217 | + | ||
| 218 | + return userDID, feedURL | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +func TestSearchArticles_FindsByTitle(t *testing.T) { | ||
| 222 | + ctx := context.Background() | ||
| 223 | + db := setupTestDB(t) | ||
| 224 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 225 | + | ||
| 226 | + results, err := db.SearchArticles(ctx, userDID, "Go Programming", 10, 0) | ||
| 227 | + assert.NilError(t, err) | ||
| 228 | + assert.Equal(t, len(results), 1) | ||
| 229 | + assert.Equal(t, results[0].Title, "Go Programming Basics") | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +func TestSearchArticles_FindsBySummary(t *testing.T) { | ||
| 233 | + ctx := context.Background() | ||
| 234 | + db := setupTestDB(t) | ||
| 235 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 236 | + | ||
| 237 | + results, err := db.SearchArticles(ctx, userDID, "ownership", 10, 0) | ||
| 238 | + assert.NilError(t, err) | ||
| 239 | + assert.Equal(t, len(results), 1) | ||
| 240 | + assert.Equal(t, results[0].Title, "Rust Memory Safety") | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +func TestSearchArticles_FindsByContent(t *testing.T) { | ||
| 244 | + ctx := context.Background() | ||
| 245 | + db := setupTestDB(t) | ||
| 246 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 247 | + | ||
| 248 | + results, err := db.SearchArticles(ctx, userDID, "garbage collection", 10, 0) | ||
| 249 | + assert.NilError(t, err) | ||
| 250 | + assert.Equal(t, len(results), 1) | ||
| 251 | + assert.Equal(t, results[0].Title, "Rust Memory Safety") | ||
| 252 | +} | ||
| 253 | + | ||
| 254 | +func TestSearchArticles_NoResults(t *testing.T) { | ||
| 255 | + ctx := context.Background() | ||
| 256 | + db := setupTestDB(t) | ||
| 257 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 258 | + | ||
| 259 | + results, err := db.SearchArticles(ctx, userDID, "nonexistent_xyz", 10, 0) | ||
| 260 | + assert.NilError(t, err) | ||
| 261 | + assert.Equal(t, len(results), 0) | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +func TestSearchArticles_MultipleMatches(t *testing.T) { | ||
| 265 | + ctx := context.Background() | ||
| 266 | + db := setupTestDB(t) | ||
| 267 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 268 | + | ||
| 269 | + results, err := db.SearchArticles(ctx, userDID, "Python", 10, 0) | ||
| 270 | + assert.NilError(t, err) | ||
| 271 | + assert.Assert(t, len(results) >= 1) | ||
| 272 | + | ||
| 273 | + var found bool | ||
| 274 | + for _, a := range results { | ||
| 275 | + if a.Title == "Python Data Science" { | ||
| 276 | + found = true | ||
| 277 | + break | ||
| 278 | + } | ||
| 279 | + } | ||
| 280 | + assert.Assert(t, found, "expected to find Python Data Science article") | ||
| 281 | +} | ||
| 282 | + | ||
| 283 | +func TestSearchArticles_ScopedToSubscriptions(t *testing.T) { | ||
| 284 | + ctx := context.Background() | ||
| 285 | + db := setupTestDB(t) | ||
| 286 | + userDID, feedURL := seedSearchData(t, ctx, db) | ||
| 287 | + | ||
| 288 | + otherFeed := "https://other.example.com/feed.xml" | ||
| 289 | + _, err := db.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, otherFeed, "Other Feed") | ||
| 290 | + assert.NilError(t, err) | ||
| 291 | + | ||
| 292 | + _, err = db.ExecContext(ctx, ` | ||
| 293 | + INSERT INTO articles (feed_url, guid, title) VALUES (?, ?, ?) | ||
| 294 | + `, otherFeed, "other-1", "Go Concurrency Tips") | ||
| 295 | + assert.NilError(t, err) | ||
| 296 | + | ||
| 297 | + results, err := db.SearchArticles(ctx, userDID, "Go", 10, 0) | ||
| 298 | + assert.NilError(t, err) | ||
| 299 | + for _, a := range results { | ||
| 300 | + assert.Equal(t, a.FeedURL, feedURL) | ||
| 301 | + } | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +func TestSearchArticles_Pagination(t *testing.T) { | ||
| 305 | + ctx := context.Background() | ||
| 306 | + db := setupTestDB(t) | ||
| 307 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 308 | + | ||
| 309 | + results, err := db.SearchArticles(ctx, userDID, "Go", 1, 0) | ||
| 310 | + assert.NilError(t, err) | ||
| 311 | + assert.Equal(t, len(results), 1) | ||
| 312 | + | ||
| 313 | + results2, err := db.SearchArticles(ctx, userDID, "Go", 1, 1) | ||
| 314 | + assert.NilError(t, err) | ||
| 315 | + assert.Assert(t, len(results2) == 0 || results2[0].ID != results[0].ID) | ||
| 316 | +} | ||
| 317 | + | ||
| 318 | +func TestSearchArticles_EmptyQuery(t *testing.T) { | ||
| 319 | + ctx := context.Background() | ||
| 320 | + db := setupTestDB(t) | ||
| 321 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 322 | + | ||
| 323 | + results, err := db.SearchArticles(ctx, userDID, "", 10, 0) | ||
| 324 | + assert.NilError(t, err) | ||
| 325 | + assert.Equal(t, len(results), 0) | ||
| 326 | +} | ||
modified
internal/db/db.go +11 -0 | @@ -206,4 +206,15 @@ var schema = []string{ | ||
| 206 | 206 | `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`, |
| 207 | 207 | `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`, |
| 208 | 208 | `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`, |
| 209 | + `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`, | |
| 210 | + `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN | |
| 211 | + INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); | |
| 212 | + END`, | |
| 213 | + `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN | |
| 214 | + INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); | |
| 215 | + END`, | |
| 216 | + `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN | |
| 217 | + INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); | |
| 218 | + INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); | |
| 219 | + END`, | |
| 209 | 220 | } |
| @@ -206,4 +206,15 @@ var schema = []string{ | |||
| 206 | `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`, | 206 | `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`, |
| 207 | `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`, | 207 | `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`, |
| 208 | `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`, | 208 | `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`, |
| 209 | + `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`, | ||
| 210 | + `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN | ||
| 211 | + INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); | ||
| 212 | + END`, | ||
| 213 | + `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN | ||
| 214 | + INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); | ||
| 215 | + END`, | ||
| 216 | + `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN | ||
| 217 | + INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); | ||
| 218 | + INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); | ||
| 219 | + END`, | ||
| 209 | } | 220 | } |
modified
internal/server/annotations_handler.go +6 -0 | @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "fmt" |
| 6 | 6 | "net/http" |
| 7 | 7 | "strconv" |
| 8 | + "strings" | |
| 8 | 9 | "time" |
| 9 | 10 | |
| 10 | 11 | "github.com/go-chi/chi/v5" |
| @@ -81,12 +82,17 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 81 | 82 | } |
| 82 | 83 | |
| 83 | 84 | if client := s.pdsClientForUser(r); client != nil { |
| 85 | + var tags []string | |
| 86 | + if a.Tags.Valid && a.Tags.String != "" { | |
| 87 | + tags = strings.Split(a.Tags.String, ",") | |
| 88 | + } | |
| 84 | 89 | record := atproto.AnnotationRecord{ |
| 85 | 90 | CreatedAt: time.Now().Format(time.RFC3339), |
| 86 | 91 | FeedURL: a.FeedURL, |
| 87 | 92 | ArticleURL: a.ArticleURL, |
| 88 | 93 | Quote: a.Quote.String, |
| 89 | 94 | Note: a.Note.String, |
| 95 | + Tags: tags, | |
| 90 | 96 | Rating: int(a.Rating.Int64), |
| 91 | 97 | } |
| 92 | 98 | uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionAnnotation, record) |
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "fmt" | 5 | "fmt" |
| 6 | "net/http" | 6 | "net/http" |
| 7 | "strconv" | 7 | "strconv" |
| 8 | + "strings" | ||
| 8 | "time" | 9 | "time" |
| 9 | 10 | ||
| 10 | "github.com/go-chi/chi/v5" | 11 | "github.com/go-chi/chi/v5" |
| @@ -81,12 +82,17 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | |||
| 81 | } | 82 | } |
| 82 | 83 | ||
| 83 | if client := s.pdsClientForUser(r); client != nil { | 84 | if client := s.pdsClientForUser(r); client != nil { |
| 85 | + var tags []string | ||
| 86 | + if a.Tags.Valid && a.Tags.String != "" { | ||
| 87 | + tags = strings.Split(a.Tags.String, ",") | ||
| 88 | + } | ||
| 84 | record := atproto.AnnotationRecord{ | 89 | record := atproto.AnnotationRecord{ |
| 85 | CreatedAt: time.Now().Format(time.RFC3339), | 90 | CreatedAt: time.Now().Format(time.RFC3339), |
| 86 | FeedURL: a.FeedURL, | 91 | FeedURL: a.FeedURL, |
| 87 | ArticleURL: a.ArticleURL, | 92 | ArticleURL: a.ArticleURL, |
| 88 | Quote: a.Quote.String, | 93 | Quote: a.Quote.String, |
| 89 | Note: a.Note.String, | 94 | Note: a.Note.String, |
| 95 | + Tags: tags, | ||
| 90 | Rating: int(a.Rating.Int64), | 96 | Rating: int(a.Rating.Int64), |
| 91 | } | 97 | } |
| 92 | uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionAnnotation, record) | 98 | uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionAnnotation, record) |
modified
internal/server/articles_handler.go +23 -10 | @@ -30,19 +30,24 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | ||
| 30 | 30 | user := currentUser(r) |
| 31 | 31 | feedURL := r.URL.Query().Get("feed") |
| 32 | 32 | status := r.URL.Query().Get("status") |
| 33 | + searchQuery := r.URL.Query().Get("q") | |
| 33 | 34 | |
| 34 | 35 | page := pageFromRequest(r, 50) |
| 35 | 36 | |
| 36 | 37 | var articles []*db.Article |
| 37 | 38 | var err error |
| 38 | 39 | |
| 39 | - switch status { | |
| 40 | - case "unread": | |
| 41 | - articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | |
| 42 | - case "read": | |
| 43 | - articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | |
| 44 | - default: | |
| 45 | - articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | |
| 40 | + if searchQuery != "" { | |
| 41 | + articles, err = s.db.SearchArticles(r.Context(), user.DID, searchQuery, page.Limit()+1, page.Offset()) | |
| 42 | + } else { | |
| 43 | + switch status { | |
| 44 | + case "unread": | |
| 45 | + articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | |
| 46 | + case "read": | |
| 47 | + articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | |
| 48 | + default: | |
| 49 | + articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | |
| 50 | + } | |
| 46 | 51 | } |
| 47 | 52 | |
| 48 | 53 | if err != nil { |
| @@ -57,16 +62,24 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | ||
| 57 | 62 | articles = articles[:page.PageSize] |
| 58 | 63 | } |
| 59 | 64 | |
| 60 | - s.render(w, r, "articles.html", map[string]any{ | |
| 65 | + data := map[string]any{ | |
| 61 | 66 | "User": user, |
| 62 | 67 | "Articles": articles, |
| 63 | 68 | "FeedURL": feedURL, |
| 64 | 69 | "Status": status, |
| 70 | + "SearchQuery": searchQuery, | |
| 65 | 71 | "Page": page, |
| 66 | 72 | "BaseURL": "/articles", |
| 67 | - "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}), | |
| 73 | + "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}), | |
| 68 | 74 | "Now": time.Now(), |
| 69 | - }) | |
| 75 | + } | |
| 76 | + | |
| 77 | + if r.Header.Get("HX-Request") == "true" { | |
| 78 | + s.render(w, r, "articles-content.html", data) | |
| 79 | + return | |
| 80 | + } | |
| 81 | + | |
| 82 | + s.render(w, r, "articles.html", data) | |
| 70 | 83 | } |
| 71 | 84 | |
| 72 | 85 | func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) { |
| @@ -30,19 +30,24 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | |||
| 30 | user := currentUser(r) | 30 | user := currentUser(r) |
| 31 | feedURL := r.URL.Query().Get("feed") | 31 | feedURL := r.URL.Query().Get("feed") |
| 32 | status := r.URL.Query().Get("status") | 32 | status := r.URL.Query().Get("status") |
| 33 | + searchQuery := r.URL.Query().Get("q") | ||
| 33 | 34 | ||
| 34 | page := pageFromRequest(r, 50) | 35 | page := pageFromRequest(r, 50) |
| 35 | 36 | ||
| 36 | var articles []*db.Article | 37 | var articles []*db.Article |
| 37 | var err error | 38 | var err error |
| 38 | 39 | ||
| 39 | - switch status { | 40 | + if searchQuery != "" { |
| 40 | - case "unread": | 41 | + articles, err = s.db.SearchArticles(r.Context(), user.DID, searchQuery, page.Limit()+1, page.Offset()) |
| 41 | - articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | 42 | + } else { |
| 42 | - case "read": | 43 | + switch status { |
| 43 | - articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | 44 | + case "unread": |
| 44 | - default: | 45 | + articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) |
| 45 | - articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | 46 | + case "read": |
| 47 | + articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | ||
| 48 | + default: | ||
| 49 | + articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset()) | ||
| 50 | + } | ||
| 46 | } | 51 | } |
| 47 | 52 | ||
| 48 | if err != nil { | 53 | if err != nil { |
| @@ -57,16 +62,24 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | |||
| 57 | articles = articles[:page.PageSize] | 62 | articles = articles[:page.PageSize] |
| 58 | } | 63 | } |
| 59 | 64 | ||
| 60 | - s.render(w, r, "articles.html", map[string]any{ | 65 | + data := map[string]any{ |
| 61 | "User": user, | 66 | "User": user, |
| 62 | "Articles": articles, | 67 | "Articles": articles, |
| 63 | "FeedURL": feedURL, | 68 | "FeedURL": feedURL, |
| 64 | "Status": status, | 69 | "Status": status, |
| 70 | + "SearchQuery": searchQuery, | ||
| 65 | "Page": page, | 71 | "Page": page, |
| 66 | "BaseURL": "/articles", | 72 | "BaseURL": "/articles", |
| 67 | - "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}), | 73 | + "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}), |
| 68 | "Now": time.Now(), | 74 | "Now": time.Now(), |
| 69 | - }) | 75 | + } |
| 76 | + | ||
| 77 | + if r.Header.Get("HX-Request") == "true" { | ||
| 78 | + s.render(w, r, "articles-content.html", data) | ||
| 79 | + return | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + s.render(w, r, "articles.html", data) | ||
| 70 | } | 83 | } |
| 71 | 84 | ||
| 72 | func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) { | 85 | func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) { |
modified
internal/tmpl/articles.html +14 -13 | @@ -10,6 +10,19 @@ | ||
| 10 | 10 | </div> |
| 11 | 11 | <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p> |
| 12 | 12 | |
| 13 | + <div class="flex items-center gap-2 mb-6"> | |
| 14 | + <form method="GET" action="/articles" class="flex-1 max-w-md" id="search-form"> | |
| 15 | + <div class="relative"> | |
| 16 | + <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..." | |
| 17 | + class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder" | |
| 18 | + hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']"> | |
| 19 | + <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> | |
| 20 | + {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}} | |
| 21 | + {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}} | |
| 22 | + </div> | |
| 23 | + </form> | |
| 24 | + </div> | |
| 25 | + | |
| 13 | 26 | <div class="flex items-center gap-2 mb-6"> |
| 14 | 27 | <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}" |
| 15 | 28 | class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition |
| @@ -29,19 +42,7 @@ | ||
| 29 | 42 | </div> |
| 30 | 43 | |
| 31 | 44 | <div id="article-list" class="space-y-3"> |
| 32 | - {{range .Articles}} | |
| 33 | - {{template "article-card.html" .}} | |
| 34 | - {{else}} | |
| 35 | - <div class="bg-spot-surface rounded-xl py-12 px-6 text-center"> | |
| 36 | - <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> | |
| 37 | - <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | |
| 38 | - </div> | |
| 39 | - <p class="text-sm text-spot-secondary mb-1">No articles found</p> | |
| 40 | - {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p> | |
| 41 | - {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p> | |
| 42 | - {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}} | |
| 43 | - </div> | |
| 44 | - {{end}} | |
| 45 | + {{template "articles-content.html" .}} | |
| 45 | 46 | </div> |
| 46 | 47 | |
| 47 | 48 | {{if or .Page.HasPrev .Page.HasNext}} |
| @@ -10,6 +10,19 @@ | |||
| 10 | </div> | 10 | </div> |
| 11 | <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p> | 11 | <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p> |
| 12 | 12 | ||
| 13 | + <div class="flex items-center gap-2 mb-6"> | ||
| 14 | + <form method="GET" action="/articles" class="flex-1 max-w-md" id="search-form"> | ||
| 15 | + <div class="relative"> | ||
| 16 | + <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..." | ||
| 17 | + class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder" | ||
| 18 | + hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']"> | ||
| 19 | + <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> | ||
| 20 | + {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}} | ||
| 21 | + {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}} | ||
| 22 | + </div> | ||
| 23 | + </form> | ||
| 24 | + </div> | ||
| 25 | + | ||
| 13 | <div class="flex items-center gap-2 mb-6"> | 26 | <div class="flex items-center gap-2 mb-6"> |
| 14 | <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}" | 27 | <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}" |
| 15 | class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition | 28 | class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition |
| @@ -29,19 +42,7 @@ | |||
| 29 | </div> | 42 | </div> |
| 30 | 43 | ||
| 31 | <div id="article-list" class="space-y-3"> | 44 | <div id="article-list" class="space-y-3"> |
| 32 | - {{range .Articles}} | 45 | + {{template "articles-content.html" .}} |
| 33 | - {{template "article-card.html" .}} | ||
| 34 | - {{else}} | ||
| 35 | - <div class="bg-spot-surface rounded-xl py-12 px-6 text-center"> | ||
| 36 | - <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> | ||
| 37 | - <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | ||
| 38 | - </div> | ||
| 39 | - <p class="text-sm text-spot-secondary mb-1">No articles found</p> | ||
| 40 | - {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p> | ||
| 41 | - {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p> | ||
| 42 | - {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}} | ||
| 43 | - </div> | ||
| 44 | - {{end}} | ||
| 45 | </div> | 46 | </div> |
| 46 | 47 | ||
| 47 | {{if or .Page.HasPrev .Page.HasNext}} | 48 | {{if or .Page.HasPrev .Page.HasNext}} |
added
internal/tmpl/partials/articles-content.html +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +{{define "articles-content.html"}} | |
| 2 | +{{range .Articles}} | |
| 3 | +{{template "article-card.html" .}} | |
| 4 | +{{else}} | |
| 5 | +<div class="bg-spot-surface rounded-xl py-12 px-6 text-center"> | |
| 6 | + <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> | |
| 7 | + <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | |
| 8 | + </div> | |
| 9 | + <p class="text-sm text-spot-secondary mb-1">No articles found</p> | |
| 10 | + {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p> | |
| 11 | + {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p> | |
| 12 | + {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}} | |
| 13 | +</div> | |
| 14 | +{{end}} | |
| 15 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +{{define "articles-content.html"}} | ||
| 2 | +{{range .Articles}} | ||
| 3 | +{{template "article-card.html" .}} | ||
| 4 | +{{else}} | ||
| 5 | +<div class="bg-spot-surface rounded-xl py-12 px-6 text-center"> | ||
| 6 | + <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto"> | ||
| 7 | + <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | ||
| 8 | + </div> | ||
| 9 | + <p class="text-sm text-spot-secondary mb-1">No articles found</p> | ||
| 10 | + {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p> | ||
| 11 | + {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p> | ||
| 12 | + {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}} | ||
| 13 | +</div> | ||
| 14 | +{{end}} | ||
| 15 | +{{end}} | ||