nandi/gleanpublic⑂ Fork 0
⑂ 9681c7f
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add read/unread filtering and keyboard shortcuts tooltip and imporve empty stateUnverified

Julien Robert committed 2026-04-20T20:13:54+02:00 Browse files
9681c7f parent: 89afff9
modified go.mod +1 -0
@@ -204,6 +204,7 @@ require (
204204 gopkg.in/ini.v1 v1.67.0 // indirect
205205 gopkg.in/yaml.v2 v2.4.0 // indirect
206206 gopkg.in/yaml.v3 v3.0.1 // indirect
207+ gotest.tools/v3 v3.5.2 // indirect
207208 honnef.co/go/tools v0.6.1 // indirect
208209 mvdan.cc/gofumpt v0.7.0 // indirect
209210 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
@@ -204,6 +204,7 @@ require (
204 gopkg.in/ini.v1 v1.67.0 // indirect204 gopkg.in/ini.v1 v1.67.0 // indirect
205 gopkg.in/yaml.v2 v2.4.0 // indirect205 gopkg.in/yaml.v2 v2.4.0 // indirect
206 gopkg.in/yaml.v3 v3.0.1 // indirect206 gopkg.in/yaml.v3 v3.0.1 // indirect
207+ gotest.tools/v3 v3.5.2 // indirect
207 honnef.co/go/tools v0.6.1 // indirect208 honnef.co/go/tools v0.6.1 // indirect
208 mvdan.cc/gofumpt v0.7.0 // indirect209 mvdan.cc/gofumpt v0.7.0 // indirect
209 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect210 mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
modified go.sum +2 -0
@@ -610,6 +610,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
610610 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
611611 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
612612 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
613+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
614+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
613615 honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
614616 honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
615617 lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
@@ -610,6 +610,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
610 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=610 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
611 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=611 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
612 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=612 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
613+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
614+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
613 honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=615 honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
614 honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=616 honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
615 lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=617 lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
modified internal/db/article.go +43 -2
@@ -103,7 +103,8 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
103103 func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
104104 query := `
105105 SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
106- a.published, a.updated, a.fetched_at
106+ a.published, a.updated, a.fetched_at,
107+ COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0)
107108 FROM articles a
108109 JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
109110 LEFT JOIN feeds f ON a.feed_url = f.feed_url
@@ -129,7 +130,47 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
129130 for rows.Next() {
130131 a := &Article{}
131132 if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
132- &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil {
133+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt,
134+ &a.IsRead, &a.IsStarred); err != nil {
135+ return nil, err
136+ }
137+ articles = append(articles, a)
138+ }
139+ return articles, rows.Err()
140+}
141+
142+func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
143+ query := `
144+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
145+ a.published, a.updated, a.fetched_at,
146+ COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0)
147+ FROM articles a
148+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
149+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
150+ JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
151+ WHERE r.is_read = 1
152+ `
153+ args := []any{userDID, userDID}
154+
155+ if feedURL != "" {
156+ query += ` AND a.feed_url = ?`
157+ args = append(args, feedURL)
158+ }
159+
160+ query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
161+
162+ rows, err := db.QueryContext(ctx, query, args...)
163+ if err != nil {
164+ return nil, err
165+ }
166+ defer rows.Close()
167+
168+ var articles []*Article
169+ for rows.Next() {
170+ a := &Article{}
171+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
172+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt,
173+ &a.IsRead, &a.IsStarred); err != nil {
133174 return nil, err
134175 }
135176 articles = append(articles, a)
@@ -103,7 +103,8 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
103 func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {103 func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
104 query := `104 query := `
105 SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,105 SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
106- a.published, a.updated, a.fetched_at106+ a.published, a.updated, a.fetched_at,
107+ COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0)
107 FROM articles a108 FROM articles a
108 JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?109 JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
109 LEFT JOIN feeds f ON a.feed_url = f.feed_url110 LEFT JOIN feeds f ON a.feed_url = f.feed_url
@@ -129,7 +130,47 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
129 for rows.Next() {130 for rows.Next() {
130 a := &Article{}131 a := &Article{}
131 if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,132 if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
132- &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil {133+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt,
134+ &a.IsRead, &a.IsStarred); err != nil {
135+ return nil, err
136+ }
137+ articles = append(articles, a)
138+ }
139+ return articles, rows.Err()
140+}
141+
142+func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) {
143+ query := `
144+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content,
145+ a.published, a.updated, a.fetched_at,
146+ COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0)
147+ FROM articles a
148+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
149+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
150+ JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
151+ WHERE r.is_read = 1
152+ `
153+ args := []any{userDID, userDID}
154+
155+ if feedURL != "" {
156+ query += ` AND a.feed_url = ?`
157+ args = append(args, feedURL)
158+ }
159+
160+ query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
161+
162+ rows, err := db.QueryContext(ctx, query, args...)
163+ if err != nil {
164+ return nil, err
165+ }
166+ defer rows.Close()
167+
168+ var articles []*Article
169+ for rows.Next() {
170+ a := &Article{}
171+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author,
172+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt,
173+ &a.IsRead, &a.IsStarred); err != nil {
133 return nil, err174 return nil, err
134 }175 }
135 articles = append(articles, a)176 articles = append(articles, a)
added internal/db/article_test.go +163 -0
new file mode 100644
@@ -0,0 +1,163 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "os"
7+ "testing"
8+
9+ "gotest.tools/v3/assert"
10+)
11+
12+func setupTestDB(t *testing.T) *DB {
13+ t.Helper()
14+ f, err := os.CreateTemp("", "glean-test-*.db")
15+ assert.NilError(t, err)
16+ assert.NilError(t, f.Close())
17+ path := f.Name()
18+ t.Cleanup(func() { _ = os.Remove(path) })
19+
20+ db, err := Open(path)
21+ assert.NilError(t, err)
22+ t.Cleanup(func() { _ = db.Close() })
23+ return db
24+}
25+
26+func seedArticleReadState(t *testing.T, ctx context.Context, db *DB) (userDID string, feedURL string, readArticleID, unreadArticleID int64) {
27+ t.Helper()
28+
29+ userDID = "did:test:user1"
30+ feedURL = "https://example.com/feed.xml"
31+
32+ _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "user1")
33+ assert.NilError(t, err)
34+
35+ _, err = db.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Test Feed")
36+ assert.NilError(t, err)
37+
38+ _, err = db.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL)
39+ assert.NilError(t, err)
40+
41+ res, err := db.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`,
42+ feedURL, "guid-read", "Read Article", "https://example.com/read")
43+ assert.NilError(t, err)
44+ readArticleID, _ = res.LastInsertId()
45+
46+ res, err = db.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`,
47+ feedURL, "guid-unread", "Unread Article", "https://example.com/unread")
48+ assert.NilError(t, err)
49+ unreadArticleID, _ = res.LastInsertId()
50+
51+ err = db.MarkArticleRead(ctx, userDID, readArticleID)
52+ assert.NilError(t, err)
53+
54+ return userDID, feedURL, readArticleID, unreadArticleID
55+}
56+
57+func TestListReadArticles_ReturnsOnlyRead(t *testing.T) {
58+ ctx := context.Background()
59+ db := setupTestDB(t)
60+ userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, db)
61+
62+ articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
63+ assert.NilError(t, err)
64+ assert.Equal(t, len(articles), 1)
65+ assert.Equal(t, articles[0].ID, readID)
66+ assert.Equal(t, articles[0].IsRead, sql.NullBool{Bool: true, Valid: true})
67+
68+ _ = unreadID
69+}
70+
71+func TestListReadArticles_ExcludesUnread(t *testing.T) {
72+ ctx := context.Background()
73+ db := setupTestDB(t)
74+ userDID, feedURL, _, unreadID := seedArticleReadState(t, ctx, db)
75+
76+ articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
77+ assert.NilError(t, err)
78+ for _, a := range articles {
79+ assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list")
80+ }
81+}
82+
83+func TestListUnreadArticles_ReturnsOnlyUnread(t *testing.T) {
84+ ctx := context.Background()
85+ db := setupTestDB(t)
86+ userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, db)
87+
88+ articles, err := db.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
89+ assert.NilError(t, err)
90+ assert.Equal(t, len(articles), 1)
91+ assert.Equal(t, articles[0].ID, unreadID)
92+ assert.Equal(t, articles[0].IsRead.Bool, false)
93+
94+ _ = readID
95+}
96+
97+func TestListArticles_ReturnsAll(t *testing.T) {
98+ ctx := context.Background()
99+ db := setupTestDB(t)
100+ userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)
101+
102+ articles, err := db.ListArticles(ctx, userDID, feedURL, 10, 0)
103+ assert.NilError(t, err)
104+ assert.Equal(t, len(articles), 2)
105+}
106+
107+func TestMarkArticleRead_ToggleUnread(t *testing.T) {
108+ ctx := context.Background()
109+ db := setupTestDB(t)
110+ userDID, _, _, unreadID := seedArticleReadState(t, ctx, db)
111+
112+ err := db.MarkArticleRead(ctx, userDID, unreadID)
113+ assert.NilError(t, err)
114+
115+ state, err := db.GetReadState(ctx, userDID, unreadID)
116+ assert.NilError(t, err)
117+ assert.Equal(t, state.IsRead, true)
118+
119+ err = db.MarkArticleUnread(ctx, userDID, unreadID)
120+ assert.NilError(t, err)
121+
122+ state, err = db.GetReadState(ctx, userDID, unreadID)
123+ assert.NilError(t, err)
124+ assert.Equal(t, state.IsRead, false)
125+}
126+
127+func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) {
128+ ctx := context.Background()
129+ db := setupTestDB(t)
130+ userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)
131+
132+ articles, err := db.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
133+ assert.NilError(t, err)
134+ assert.Equal(t, len(articles), 1)
135+}
136+
137+func TestListReadArticles_WithFeedURLFilter(t *testing.T) {
138+ ctx := context.Background()
139+ db := setupTestDB(t)
140+ userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)
141+
142+ articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
143+ assert.NilError(t, err)
144+ assert.Equal(t, len(articles), 1)
145+
146+ articles, err = db.ListReadArticles(ctx, userDID, "https://other.com/feed", 10, 0)
147+ assert.NilError(t, err)
148+ assert.Equal(t, len(articles), 0)
149+}
150+
151+func TestGetUnreadCount(t *testing.T) {
152+ ctx := context.Background()
153+ db := setupTestDB(t)
154+ userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)
155+
156+ count, err := db.GetUnreadCount(ctx, userDID, feedURL)
157+ assert.NilError(t, err)
158+ assert.Equal(t, count, 1)
159+
160+ count, err = db.GetUnreadCount(ctx, userDID, "")
161+ assert.NilError(t, err)
162+ assert.Equal(t, count, 1)
163+}
new file mode 100644
@@ -0,0 +1,163 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "os"
7+ "testing"
8+
9+ "gotest.tools/v3/assert"
10+)
11+
12+func setupTestDB(t *testing.T) *DB {
13+ t.Helper()
14+ f, err := os.CreateTemp("", "glean-test-*.db")
15+ assert.NilError(t, err)
16+ assert.NilError(t, f.Close())
17+ path := f.Name()
18+ t.Cleanup(func() { _ = os.Remove(path) })
19+
20+ db, err := Open(path)
21+ assert.NilError(t, err)
22+ t.Cleanup(func() { _ = db.Close() })
23+ return db
24+}
25+
26+func seedArticleReadState(t *testing.T, ctx context.Context, db *DB) (userDID string, feedURL string, readArticleID, unreadArticleID int64) {
27+ t.Helper()
28+
29+ userDID = "did:test:user1"
30+ feedURL = "https://example.com/feed.xml"
31+
32+ _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "user1")
33+ assert.NilError(t, err)
34+
35+ _, err = db.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Test Feed")
36+ assert.NilError(t, err)
37+
38+ _, err = db.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL)
39+ assert.NilError(t, err)
40+
41+ res, err := db.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`,
42+ feedURL, "guid-read", "Read Article", "https://example.com/read")
43+ assert.NilError(t, err)
44+ readArticleID, _ = res.LastInsertId()
45+
46+ res, err = db.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`,
47+ feedURL, "guid-unread", "Unread Article", "https://example.com/unread")
48+ assert.NilError(t, err)
49+ unreadArticleID, _ = res.LastInsertId()
50+
51+ err = db.MarkArticleRead(ctx, userDID, readArticleID)
52+ assert.NilError(t, err)
53+
54+ return userDID, feedURL, readArticleID, unreadArticleID
55+}
56+
57+func TestListReadArticles_ReturnsOnlyRead(t *testing.T) {
58+ ctx := context.Background()
59+ db := setupTestDB(t)
60+ userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, db)
61+
62+ articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
63+ assert.NilError(t, err)
64+ assert.Equal(t, len(articles), 1)
65+ assert.Equal(t, articles[0].ID, readID)
66+ assert.Equal(t, articles[0].IsRead, sql.NullBool{Bool: true, Valid: true})
67+
68+ _ = unreadID
69+}
70+
71+func TestListReadArticles_ExcludesUnread(t *testing.T) {
72+ ctx := context.Background()
73+ db := setupTestDB(t)
74+ userDID, feedURL, _, unreadID := seedArticleReadState(t, ctx, db)
75+
76+ articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
77+ assert.NilError(t, err)
78+ for _, a := range articles {
79+ assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list")
80+ }
81+}
82+
83+func TestListUnreadArticles_ReturnsOnlyUnread(t *testing.T) {
84+ ctx := context.Background()
85+ db := setupTestDB(t)
86+ userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, db)
87+
88+ articles, err := db.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
89+ assert.NilError(t, err)
90+ assert.Equal(t, len(articles), 1)
91+ assert.Equal(t, articles[0].ID, unreadID)
92+ assert.Equal(t, articles[0].IsRead.Bool, false)
93+
94+ _ = readID
95+}
96+
97+func TestListArticles_ReturnsAll(t *testing.T) {
98+ ctx := context.Background()
99+ db := setupTestDB(t)
100+ userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)
101+
102+ articles, err := db.ListArticles(ctx, userDID, feedURL, 10, 0)
103+ assert.NilError(t, err)
104+ assert.Equal(t, len(articles), 2)
105+}
106+
107+func TestMarkArticleRead_ToggleUnread(t *testing.T) {
108+ ctx := context.Background()
109+ db := setupTestDB(t)
110+ userDID, _, _, unreadID := seedArticleReadState(t, ctx, db)
111+
112+ err := db.MarkArticleRead(ctx, userDID, unreadID)
113+ assert.NilError(t, err)
114+
115+ state, err := db.GetReadState(ctx, userDID, unreadID)
116+ assert.NilError(t, err)
117+ assert.Equal(t, state.IsRead, true)
118+
119+ err = db.MarkArticleUnread(ctx, userDID, unreadID)
120+ assert.NilError(t, err)
121+
122+ state, err = db.GetReadState(ctx, userDID, unreadID)
123+ assert.NilError(t, err)
124+ assert.Equal(t, state.IsRead, false)
125+}
126+
127+func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) {
128+ ctx := context.Background()
129+ db := setupTestDB(t)
130+ userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)
131+
132+ articles, err := db.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
133+ assert.NilError(t, err)
134+ assert.Equal(t, len(articles), 1)
135+}
136+
137+func TestListReadArticles_WithFeedURLFilter(t *testing.T) {
138+ ctx := context.Background()
139+ db := setupTestDB(t)
140+ userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)
141+
142+ articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
143+ assert.NilError(t, err)
144+ assert.Equal(t, len(articles), 1)
145+
146+ articles, err = db.ListReadArticles(ctx, userDID, "https://other.com/feed", 10, 0)
147+ assert.NilError(t, err)
148+ assert.Equal(t, len(articles), 0)
149+}
150+
151+func TestGetUnreadCount(t *testing.T) {
152+ ctx := context.Background()
153+ db := setupTestDB(t)
154+ userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)
155+
156+ count, err := db.GetUnreadCount(ctx, userDID, feedURL)
157+ assert.NilError(t, err)
158+ assert.Equal(t, count, 1)
159+
160+ count, err = db.GetUnreadCount(ctx, userDID, "")
161+ assert.NilError(t, err)
162+ assert.Equal(t, count, 1)
163+}
modified internal/server/articles_handler.go +20 -15
@@ -22,23 +22,25 @@ func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count i
2222 _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="#like-btn" hx-swap="outerHTML" id="like-btn" class="text-lg %s">&#9829; <span class="text-sm text-gray-600">%d</span></button>`, articleID, cls, count)
2323 }
2424
25-func writeReadButton(w http.ResponseWriter, articleID int64, isRead bool) {
26- label := "Mark read"
27- action := "read"
28- if isRead {
29- label = "Mark unread"
30- action = "unread"
31- }
32- w.Header().Set("Content-Type", "text/html")
33- _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/%s" hx-target="#read-btn" hx-swap="outerHTML" id="read-btn" class="text-xs border border-gray-300 rounded px-2 py-1 hover:bg-gray-50">%s</button>`, articleID, action, label)
34-}
35-
3625 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
3726 user := currentUser(r)
3827 feedURL := r.URL.Query().Get("feed")
28+ status := r.URL.Query().Get("status")
3929
4030 page := pageFromRequest(r, 50)
41- articles, err := s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
31+
32+ var articles []*db.Article
33+ var err error
34+
35+ switch status {
36+ case "unread":
37+ articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
38+ case "read":
39+ articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
40+ default:
41+ articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
42+ }
43+
4244 if err != nil {
4345 s.logger.Error("failed to list articles", "error", err)
4446 http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -55,9 +57,10 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
5557 "User": user,
5658 "Articles": articles,
5759 "FeedURL": feedURL,
60+ "Status": status,
5861 "Page": page,
5962 "BaseURL": "/articles",
60- "QueryParams": buildQueryParams(map[string]string{"feed": feedURL}),
63+ "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}),
6164 })
6265 }
6366
@@ -109,7 +112,8 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
109112 http.Error(w, err.Error(), http.StatusInternalServerError)
110113 return
111114 }
112- writeReadButton(w, id, true)
115+ w.Header().Set("Content-Type", "text/html")
116+ _, _ = fmt.Fprintf(w, `<span id="read-btn-%d" class="text-xs text-spot-green uppercase tracking-button">Read</span>`, id)
113117 }
114118
115119 func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
@@ -123,7 +127,8 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
123127 http.Error(w, err.Error(), http.StatusInternalServerError)
124128 return
125129 }
126- writeReadButton(w, id, false)
130+ w.Header().Set("Content-Type", "text/html")
131+ _, _ = fmt.Fprintf(w, `<span id="read-btn-%d" class="text-xs text-spot-muted uppercase tracking-button"></span>`, id)
127132 }
128133
129134 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
@@ -22,23 +22,25 @@ func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count i
22 _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="#like-btn" hx-swap="outerHTML" id="like-btn" class="text-lg %s">&#9829; <span class="text-sm text-gray-600">%d</span></button>`, articleID, cls, count)22 _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="#like-btn" hx-swap="outerHTML" id="like-btn" class="text-lg %s">&#9829; <span class="text-sm text-gray-600">%d</span></button>`, articleID, cls, count)
23 }23 }
24 24
25-func writeReadButton(w http.ResponseWriter, articleID int64, isRead bool) {
26- label := "Mark read"
27- action := "read"
28- if isRead {
29- label = "Mark unread"
30- action = "unread"
31- }
32- w.Header().Set("Content-Type", "text/html")
33- _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/%s" hx-target="#read-btn" hx-swap="outerHTML" id="read-btn" class="text-xs border border-gray-300 rounded px-2 py-1 hover:bg-gray-50">%s</button>`, articleID, action, label)
34-}
35-
36 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {25 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
37 user := currentUser(r)26 user := currentUser(r)
38 feedURL := r.URL.Query().Get("feed")27 feedURL := r.URL.Query().Get("feed")
28+ status := r.URL.Query().Get("status")
39 29
40 page := pageFromRequest(r, 50)30 page := pageFromRequest(r, 50)
41- articles, err := s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())31+
32+ var articles []*db.Article
33+ var err error
34+
35+ switch status {
36+ case "unread":
37+ articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
38+ case "read":
39+ articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
40+ default:
41+ articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
42+ }
43+
42 if err != nil {44 if err != nil {
43 s.logger.Error("failed to list articles", "error", err)45 s.logger.Error("failed to list articles", "error", err)
44 http.Error(w, err.Error(), http.StatusInternalServerError)46 http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -55,9 +57,10 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
55 "User": user,57 "User": user,
56 "Articles": articles,58 "Articles": articles,
57 "FeedURL": feedURL,59 "FeedURL": feedURL,
60+ "Status": status,
58 "Page": page,61 "Page": page,
59 "BaseURL": "/articles",62 "BaseURL": "/articles",
60- "QueryParams": buildQueryParams(map[string]string{"feed": feedURL}),63+ "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}),
61 })64 })
62 }65 }
63 66
@@ -109,7 +112,8 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
109 http.Error(w, err.Error(), http.StatusInternalServerError)112 http.Error(w, err.Error(), http.StatusInternalServerError)
110 return113 return
111 }114 }
112- writeReadButton(w, id, true)115+ w.Header().Set("Content-Type", "text/html")
116+ _, _ = fmt.Fprintf(w, `<span id="read-btn-%d" class="text-xs text-spot-green uppercase tracking-button">Read</span>`, id)
113 }117 }
114 118
115 func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {119 func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
@@ -123,7 +127,8 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
123 http.Error(w, err.Error(), http.StatusInternalServerError)127 http.Error(w, err.Error(), http.StatusInternalServerError)
124 return128 return
125 }129 }
126- writeReadButton(w, id, false)130+ w.Header().Set("Content-Type", "text/html")
131+ _, _ = fmt.Fprintf(w, `<span id="read-btn-%d" class="text-xs text-spot-muted uppercase tracking-button"></span>`, id)
127 }132 }
128 133
129 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {134 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
modified internal/tmpl/article_detail.html +28 -18
@@ -3,7 +3,7 @@
33 <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">&larr; Back to articles</a>
44
55 <article>
6- <h1 class="text-3xl font-bold leading-tight">
6+ <h1 class="text-2xl font-bold leading-tight">
77 {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-green transition">{{.Article.Title}}</a>
88 {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}}
99 </h1>
@@ -19,31 +19,41 @@
1919 {{end}}
2020 </div>
2121
22- <div class="flex items-center gap-3 mt-4">
23- <button hx-post="/articles/{{.Article.ID}}/like" hx-target="#like-btn" hx-swap="outerHTML"
24- id="like-btn"
25- class="text-lg {{if .HasLiked}}text-spot-red{{else}}text-spot-muted hover:text-spot-red{{end}} transition">
26- &#9829; <span class="text-sm text-spot-secondary">{{.LikeCount}}</span>
27- </button>
28-
29- <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"
30- target="_blank" rel="noopener noreferrer"
31- class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1">
32- <span class="w-3.5 h-3.5 inline-flex">{{template "icon-bluesky"}}</span>
33- Share
34- </a>
35-
22+ <div class="flex items-center gap-2 mt-6 flex-wrap">
3623 <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}"
3724 hx-target="#read-btn" hx-swap="outerHTML"
3825 id="read-btn"
39- class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition">
40- {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}}
26+ class="border border-spot-outline text-spot-text rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
27+ {{if .ReadState.IsRead}}
28+ <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="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.64 0 8.577 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.64 0-8.577-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
29+ Mark unread
30+ {{else}}
31+ <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="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.64 0 8.577 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.64 0-8.577-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
32+ Mark read
33+ {{end}}
4134 </button>
4235
36+ <button hx-post="/articles/{{.Article.ID}}/like" hx-target="#like-btn" hx-swap="outerHTML"
37+ id="like-btn"
38+ class="border border-spot-outline text-spot-text rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
39+ <span class="{{if .HasLiked}}text-spot-red{{end}}">&#9829;</span>
40+ {{.LikeCount}}
41+ </button>
42+
43+ {{if .Article.URL.Valid}}
4344 <a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer"
44- class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition">
45+ class="border border-spot-outline text-spot-text rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
46+ <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="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-4.5-6H18m0 0v4.5m0-4.5L10.5 13.5"/></svg>
4547 Original
4648 </a>
49+
50+ <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{.Article.URL.String}}"
51+ target="_blank" rel="noopener noreferrer"
52+ class="border border-spot-outline text-spot-text rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
53+ <span class="w-3.5 h-3.5 inline-flex">{{template "icon-bluesky"}}</span>
54+ Share
55+ </a>
56+ {{end}}
4757 </div>
4858
4959 <hr class="my-6 border-spot-divider-30">
@@ -3,7 +3,7 @@
3 <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">&larr; Back to articles</a>3 <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">&larr; Back to articles</a>
4 4
5 <article>5 <article>
6- <h1 class="text-3xl font-bold leading-tight">6+ <h1 class="text-2xl font-bold leading-tight">
7 {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-green transition">{{.Article.Title}}</a>7 {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-green transition">{{.Article.Title}}</a>
8 {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}}8 {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}}
9 </h1>9 </h1>
@@ -19,31 +19,41 @@
19 {{end}}19 {{end}}
20 </div>20 </div>
21 21
22- <div class="flex items-center gap-3 mt-4">22+ <div class="flex items-center gap-2 mt-6 flex-wrap">
23- <button hx-post="/articles/{{.Article.ID}}/like" hx-target="#like-btn" hx-swap="outerHTML"
24- id="like-btn"
25- class="text-lg {{if .HasLiked}}text-spot-red{{else}}text-spot-muted hover:text-spot-red{{end}} transition">
26- &#9829; <span class="text-sm text-spot-secondary">{{.LikeCount}}</span>
27- </button>
28-
29- <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"
30- target="_blank" rel="noopener noreferrer"
31- class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1">
32- <span class="w-3.5 h-3.5 inline-flex">{{template "icon-bluesky"}}</span>
33- Share
34- </a>
35-
36 <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}"23 <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}"
37 hx-target="#read-btn" hx-swap="outerHTML"24 hx-target="#read-btn" hx-swap="outerHTML"
38 id="read-btn"25 id="read-btn"
39- class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition">26+ class="border border-spot-outline text-spot-text rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
40- {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}}27+ {{if .ReadState.IsRead}}
28+ <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="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.64 0 8.577 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.64 0-8.577-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
29+ Mark unread
30+ {{else}}
31+ <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="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.64 0 8.577 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.64 0-8.577-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
32+ Mark read
33+ {{end}}
41 </button>34 </button>
42 35
36+ <button hx-post="/articles/{{.Article.ID}}/like" hx-target="#like-btn" hx-swap="outerHTML"
37+ id="like-btn"
38+ class="border border-spot-outline text-spot-text rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
39+ <span class="{{if .HasLiked}}text-spot-red{{end}}">&#9829;</span>
40+ {{.LikeCount}}
41+ </button>
42+
43+ {{if .Article.URL.Valid}}
43 <a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer"44 <a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer"
44- class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition">45+ class="border border-spot-outline text-spot-text rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
46+ <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="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-4.5-6H18m0 0v4.5m0-4.5L10.5 13.5"/></svg>
45 Original47 Original
46 </a>48 </a>
49+
50+ <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{.Article.URL.String}}"
51+ target="_blank" rel="noopener noreferrer"
52+ class="border border-spot-outline text-spot-text rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
53+ <span class="w-3.5 h-3.5 inline-flex">{{template "icon-bluesky"}}</span>
54+ Share
55+ </a>
56+ {{end}}
47 </div>57 </div>
48 58
49 <hr class="my-6 border-spot-divider-30">59 <hr class="my-6 border-spot-divider-30">
modified internal/tmpl/articles.html +27 -1
@@ -9,11 +9,37 @@
99 </div>
1010 <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p>
1111
12+ <div class="flex items-center gap-2 mb-6">
13+ <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
14+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
15+ {{if eq .Status ""}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
16+ All
17+ </a>
18+ <a href="/articles?status=unread{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
19+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
20+ {{if eq .Status "unread"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
21+ Unread
22+ </a>
23+ <a href="/articles?status=read{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
24+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
25+ {{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}}">
26+ Read
27+ </a>
28+ </div>
29+
1230 <div id="article-list" class="space-y-3">
1331 {{range .Articles}}
1432 {{template "article-card.html" .}}
1533 {{else}}
16- <div class="text-center text-spot-secondary py-12">No articles found.</div>
34+ <div class="flex flex-col items-center justify-center py-16 text-center">
35+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
36+ <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>
37+ </div>
38+ <p class="text-sm text-spot-secondary mb-1">No articles found</p>
39+ {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p>
40+ {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p>
41+ {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
42+ </div>
1743 {{end}}
1844 </div>
1945
@@ -9,11 +9,37 @@
9 </div>9 </div>
10 <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p>10 <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p>
11 11
12+ <div class="flex items-center gap-2 mb-6">
13+ <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
14+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
15+ {{if eq .Status ""}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
16+ All
17+ </a>
18+ <a href="/articles?status=unread{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
19+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
20+ {{if eq .Status "unread"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
21+ Unread
22+ </a>
23+ <a href="/articles?status=read{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
24+ class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
25+ {{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}}">
26+ Read
27+ </a>
28+ </div>
29+
12 <div id="article-list" class="space-y-3">30 <div id="article-list" class="space-y-3">
13 {{range .Articles}}31 {{range .Articles}}
14 {{template "article-card.html" .}}32 {{template "article-card.html" .}}
15 {{else}}33 {{else}}
16- <div class="text-center text-spot-secondary py-12">No articles found.</div>34+ <div class="flex flex-col items-center justify-center py-16 text-center">
35+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
36+ <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>
37+ </div>
38+ <p class="text-sm text-spot-secondary mb-1">No articles found</p>
39+ {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p>
40+ {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p>
41+ {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
42+ </div>
17 {{end}}43 {{end}}
18 </div>44 </div>
19 45
modified internal/tmpl/base.html +34 -6
@@ -39,6 +39,27 @@
3939 <button id="confirm-dialog-ok" class="text-sm text-white bg-spot-red hover:brightness-110 px-4 py-2 rounded-pill font-bold uppercase tracking-button transition">Confirm</button>
4040 </div>
4141 </dialog>
42+ <dialog id="shortcuts-dialog" class="bg-spot-surface rounded-xl p-6 max-w-xs shadow-lg border border-spot-divider backdrop:bg-black/50" style="color-scheme:dark" onclick="if(event.target===this)this.close()">
43+ <h3 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-4">Keyboard shortcuts</h3>
44+ <div class="space-y-2 text-sm">
45+ <div class="text-spot-secondary font-bold text-xs uppercase tracking-wide">Navigation</div>
46+ <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
47+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">g</kbd><span class="text-spot-secondary">Dashboard</span>
48+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">a</kbd><span class="text-spot-secondary">Articles</span>
49+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">f</kbd><span class="text-spot-secondary">Feeds</span>
50+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">t</kbd><span class="text-spot-secondary">Trending</span>
51+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">l</kbd><span class="text-spot-secondary">Library</span>
52+ </div>
53+ <div class="text-spot-secondary font-bold text-xs uppercase tracking-wide pt-2">Articles</div>
54+ <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
55+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">j</kbd><span class="text-spot-secondary">Next article</span>
56+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">k</kbd><span class="text-spot-secondary">Previous article</span>
57+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">o</kbd><span class="text-spot-secondary">Open article</span>
58+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">m</kbd><span class="text-spot-secondary">Toggle read</span>
59+ </div>
60+ </div>
61+ <button onclick="document.getElementById('shortcuts-dialog').close()" class="mt-4 w-full text-sm text-spot-secondary hover:text-spot-text px-4 py-2 rounded-pill border border-spot-outline transition">Close</button>
62+ </dialog>
4263 {{if .User}}
4364 <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">
4465 <div class="mb-8 px-3">
@@ -158,6 +179,10 @@
158179 <span class="theme-text-dark">Light mode</span>
159180 <span class="theme-text-light hidden">Dark mode</span>
160181 </button>
182+ <button onclick="document.getElementById('shortcuts-dialog').showModal()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
183+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z"/></svg>
184+ Keyboard shortcuts
185+ </button>
161186 </div>
162187 </div>
163188 <div class="text-right">
@@ -182,12 +207,15 @@
182207 </div>
183208 </div>
184209 <div class="flex items-center justify-between">
185- <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
186- <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
187- <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
188- <span class="theme-text-dark">Light mode</span>
189- <span class="theme-text-light hidden">Dark mode</span>
190- </button>
210+ <div class="flex items-center gap-4">
211+ <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
212+ <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
213+ <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
214+ <span class="theme-text-dark">Light mode</span>
215+ <span class="theme-text-light hidden">Dark mode</span>
216+ </button>
217+ <button onclick="document.getElementById('shortcuts-dialog').showModal()" class="text-xs text-spot-secondary hover:text-spot-text transition">Shortcuts</button>
218+ </div>
191219 <span class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} Glean</span>
192220 </div>
193221 </div>
@@ -39,6 +39,27 @@
39 <button id="confirm-dialog-ok" class="text-sm text-white bg-spot-red hover:brightness-110 px-4 py-2 rounded-pill font-bold uppercase tracking-button transition">Confirm</button>39 <button id="confirm-dialog-ok" class="text-sm text-white bg-spot-red hover:brightness-110 px-4 py-2 rounded-pill font-bold uppercase tracking-button transition">Confirm</button>
40 </div>40 </div>
41 </dialog>41 </dialog>
42+ <dialog id="shortcuts-dialog" class="bg-spot-surface rounded-xl p-6 max-w-xs shadow-lg border border-spot-divider backdrop:bg-black/50" style="color-scheme:dark" onclick="if(event.target===this)this.close()">
43+ <h3 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-4">Keyboard shortcuts</h3>
44+ <div class="space-y-2 text-sm">
45+ <div class="text-spot-secondary font-bold text-xs uppercase tracking-wide">Navigation</div>
46+ <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
47+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">g</kbd><span class="text-spot-secondary">Dashboard</span>
48+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">a</kbd><span class="text-spot-secondary">Articles</span>
49+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">f</kbd><span class="text-spot-secondary">Feeds</span>
50+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">t</kbd><span class="text-spot-secondary">Trending</span>
51+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">l</kbd><span class="text-spot-secondary">Library</span>
52+ </div>
53+ <div class="text-spot-secondary font-bold text-xs uppercase tracking-wide pt-2">Articles</div>
54+ <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
55+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">j</kbd><span class="text-spot-secondary">Next article</span>
56+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">k</kbd><span class="text-spot-secondary">Previous article</span>
57+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">o</kbd><span class="text-spot-secondary">Open article</span>
58+ <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">m</kbd><span class="text-spot-secondary">Toggle read</span>
59+ </div>
60+ </div>
61+ <button onclick="document.getElementById('shortcuts-dialog').close()" class="mt-4 w-full text-sm text-spot-secondary hover:text-spot-text px-4 py-2 rounded-pill border border-spot-outline transition">Close</button>
62+ </dialog>
42 {{if .User}}63 {{if .User}}
43 <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">64 <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">
44 <div class="mb-8 px-3">65 <div class="mb-8 px-3">
@@ -158,6 +179,10 @@
158 <span class="theme-text-dark">Light mode</span>179 <span class="theme-text-dark">Light mode</span>
159 <span class="theme-text-light hidden">Dark mode</span>180 <span class="theme-text-light hidden">Dark mode</span>
160 </button>181 </button>
182+ <button onclick="document.getElementById('shortcuts-dialog').showModal()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
183+ <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z"/></svg>
184+ Keyboard shortcuts
185+ </button>
161 </div>186 </div>
162 </div>187 </div>
163 <div class="text-right">188 <div class="text-right">
@@ -182,12 +207,15 @@
182 </div>207 </div>
183 </div>208 </div>
184 <div class="flex items-center justify-between">209 <div class="flex items-center justify-between">
185- <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">210+ <div class="flex items-center gap-4">
186- <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>211+ <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
187- <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>212+ <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
188- <span class="theme-text-dark">Light mode</span>213+ <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
189- <span class="theme-text-light hidden">Dark mode</span>214+ <span class="theme-text-dark">Light mode</span>
190- </button>215+ <span class="theme-text-light hidden">Dark mode</span>
216+ </button>
217+ <button onclick="document.getElementById('shortcuts-dialog').showModal()" class="text-xs text-spot-secondary hover:text-spot-text transition">Shortcuts</button>
218+ </div>
191 <span class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} Glean</span>219 <span class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} Glean</span>
192 </div>220 </div>
193 </div>221 </div>
modified internal/tmpl/dashboard.html +7 -1
@@ -34,7 +34,13 @@
3434 {{range .Articles}}
3535 {{template "article-card.html" .}}
3636 {{else}}
37- <div class="text-center text-spot-secondary py-12">No unread articles. You're all caught up!</div>
37+ <div class="flex flex-col items-center justify-center py-12 text-center">
38+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
39+ <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="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
40+ </div>
41+ <p class="text-sm text-spot-secondary mb-1">No unread articles</p>
42+ <p class="text-xs text-spot-muted">You're all caught up!</p>
43+ </div>
3844 {{end}}
3945 </div>
4046
@@ -34,7 +34,13 @@
34 {{range .Articles}}34 {{range .Articles}}
35 {{template "article-card.html" .}}35 {{template "article-card.html" .}}
36 {{else}}36 {{else}}
37- <div class="text-center text-spot-secondary py-12">No unread articles. You're all caught up!</div>37+ <div class="flex flex-col items-center justify-center py-12 text-center">
38+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
39+ <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="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
40+ </div>
41+ <p class="text-sm text-spot-secondary mb-1">No unread articles</p>
42+ <p class="text-xs text-spot-muted">You're all caught up!</p>
43+ </div>
38 {{end}}44 {{end}}
39 </div>45 </div>
40 46
modified internal/tmpl/feeds.html +6 -2
@@ -86,8 +86,12 @@
8686 </div>
8787 </div>
8888 {{else}}
89- <div class="px-4 py-8 text-center text-spot-secondary">
90- <p>No feeds yet. Add one from the sidebar.</p>
89+ <div class="flex flex-col items-center justify-center py-12 text-center">
90+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
91+ <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="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
92+ </div>
93+ <p class="text-sm text-spot-secondary mb-1">No feeds yet</p>
94+ <p class="text-xs text-spot-muted">Add one from the sidebar to get started.</p>
9195 </div>
9296 {{end}}
9397 </div>
@@ -86,8 +86,12 @@
86 </div>86 </div>
87 </div>87 </div>
88 {{else}}88 {{else}}
89- <div class="px-4 py-8 text-center text-spot-secondary">89+ <div class="flex flex-col items-center justify-center py-12 text-center">
90- <p>No feeds yet. Add one from the sidebar.</p>90+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
91+ <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="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
92+ </div>
93+ <p class="text-sm text-spot-secondary mb-1">No feeds yet</p>
94+ <p class="text-xs text-spot-muted">Add one from the sidebar to get started.</p>
91 </div>95 </div>
92 {{end}}96 {{end}}
93 </div>97 </div>
modified internal/tmpl/library.html +12 -6
@@ -12,9 +12,12 @@
1212 {{range .Articles}}
1313 {{template "article-card.html" .}}
1414 {{else}}
15- <div class="text-center text-spot-secondary py-12">
16- <p>No liked articles yet.</p>
17- <p class="text-sm mt-1">Like articles to save them here.</p>
15+ <div class="flex flex-col items-center justify-center py-12 text-center">
16+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
17+ <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="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg>
18+ </div>
19+ <p class="text-sm text-spot-secondary mb-1">No liked articles yet</p>
20+ <p class="text-xs text-spot-muted">Like articles to save them here.</p>
1821 </div>
1922 {{end}}
2023 </div>
@@ -51,9 +54,12 @@
5154 {{template "annotation-card.html" .}}
5255 </div>
5356 {{else}}
54- <div class="text-center text-spot-secondary py-12">
55- <p>No annotations yet.</p>
56- <p class="text-sm mt-1">Highlight and annotate articles as you read.</p>
57+ <div class="flex flex-col items-center justify-center py-12 text-center">
58+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
59+ <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="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
60+ </div>
61+ <p class="text-sm text-spot-secondary mb-1">No annotations yet</p>
62+ <p class="text-xs text-spot-muted">Highlight and annotate articles as you read.</p>
5763 </div>
5864 {{end}}
5965 </div>
@@ -12,9 +12,12 @@
12 {{range .Articles}}12 {{range .Articles}}
13 {{template "article-card.html" .}}13 {{template "article-card.html" .}}
14 {{else}}14 {{else}}
15- <div class="text-center text-spot-secondary py-12">15+ <div class="flex flex-col items-center justify-center py-12 text-center">
16- <p>No liked articles yet.</p>16+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
17- <p class="text-sm mt-1">Like articles to save them here.</p>17+ <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="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg>
18+ </div>
19+ <p class="text-sm text-spot-secondary mb-1">No liked articles yet</p>
20+ <p class="text-xs text-spot-muted">Like articles to save them here.</p>
18 </div>21 </div>
19 {{end}}22 {{end}}
20 </div>23 </div>
@@ -51,9 +54,12 @@
51 {{template "annotation-card.html" .}}54 {{template "annotation-card.html" .}}
52 </div>55 </div>
53 {{else}}56 {{else}}
54- <div class="text-center text-spot-secondary py-12">57+ <div class="flex flex-col items-center justify-center py-12 text-center">
55- <p>No annotations yet.</p>58+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4">
56- <p class="text-sm mt-1">Highlight and annotate articles as you read.</p>59+ <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="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
60+ </div>
61+ <p class="text-sm text-spot-secondary mb-1">No annotations yet</p>
62+ <p class="text-xs text-spot-muted">Highlight and annotate articles as you read.</p>
57 </div>63 </div>
58 {{end}}64 {{end}}
59 </div>65 </div>
modified internal/tmpl/partials/article-card.html +10 -2
@@ -1,8 +1,11 @@
11 {{define "article-card.html"}}
2-<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" class="bg-spot-surface rounded-xl px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot relative">
2+<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" class="bg-spot-surface rounded-xl px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot relative {{if .IsRead.Bool}}opacity-60{{end}}">
33 <div class="flex items-start justify-between gap-4">
44 <div class="min-w-0 flex-1">
5- <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a>
5+ <div class="flex items-center gap-2">
6+ {{if not .IsRead.Bool}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0"></span>{{end}}
7+ <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a>
8+ </div>
69 <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2">
710 {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}}
811 {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
@@ -14,6 +17,11 @@
1417 </div>
1518 <div class="flex flex-col items-center gap-1 shrink-0">
1619 {{template "like-button.html" .}}
20+ {{if .IsRead.Bool}}
21+ <span id="read-btn-{{.ID}}" class="text-[10px] text-spot-green uppercase tracking-button">Read</span>
22+ {{else}}
23+ <button hx-post="/articles/{{.ID}}/read" hx-target="#read-btn-{{.ID}}" hx-swap="outerHTML" id="read-btn-{{.ID}}" class="text-[10px] text-spot-secondary hover:text-spot-text uppercase tracking-button transition">Mark read</button>
24+ {{end}}
1725 </div>
1826 </div>
1927 <div class="annotate-form hidden mt-3 pt-3 border-t border-spot-divider">
@@ -1,8 +1,11 @@
1 {{define "article-card.html"}}1 {{define "article-card.html"}}
2-<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" class="bg-spot-surface rounded-xl px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot relative">2+<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" class="bg-spot-surface rounded-xl px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot relative {{if .IsRead.Bool}}opacity-60{{end}}">
3 <div class="flex items-start justify-between gap-4">3 <div class="flex items-start justify-between gap-4">
4 <div class="min-w-0 flex-1">4 <div class="min-w-0 flex-1">
5- <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a>5+ <div class="flex items-center gap-2">
6+ {{if not .IsRead.Bool}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0"></span>{{end}}
7+ <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a>
8+ </div>
6 <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2">9 <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2">
7 {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}}10 {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}}
8 {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}11 {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
@@ -14,6 +17,11 @@
14 </div>17 </div>
15 <div class="flex flex-col items-center gap-1 shrink-0">18 <div class="flex flex-col items-center gap-1 shrink-0">
16 {{template "like-button.html" .}}19 {{template "like-button.html" .}}
20+ {{if .IsRead.Bool}}
21+ <span id="read-btn-{{.ID}}" class="text-[10px] text-spot-green uppercase tracking-button">Read</span>
22+ {{else}}
23+ <button hx-post="/articles/{{.ID}}/read" hx-target="#read-btn-{{.ID}}" hx-swap="outerHTML" id="read-btn-{{.ID}}" class="text-[10px] text-spot-secondary hover:text-spot-text uppercase tracking-button transition">Mark read</button>
24+ {{end}}
17 </div>25 </div>
18 </div>26 </div>
19 <div class="annotate-form hidden mt-3 pt-3 border-t border-spot-divider">27 <div class="annotate-form hidden mt-3 pt-3 border-t border-spot-divider">
modified main.go +1 -0
@@ -75,6 +75,7 @@ func main() {
7575 }
7676
7777 sigCh := make(chan os.Signal, 1)
78+ signal.Reset(syscall.SIGINT, syscall.SIGTERM)
7879 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
7980
8081 go func() {
@@ -75,6 +75,7 @@ func main() {
75 }75 }
76 76
77 sigCh := make(chan os.Signal, 1)77 sigCh := make(chan os.Signal, 1)
78+ signal.Reset(syscall.SIGINT, syscall.SIGTERM)
78 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)79 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
79 80
80 go func() {81 go func() {