Add read/unread filtering and keyboard shortcuts tooltip and imporve empty stateUnverified
9681c7f parent: 89afff9 modified
go.mod +1 -0 | @@ -204,6 +204,7 @@ require ( | ||
| 204 | 204 | gopkg.in/ini.v1 v1.67.0 // indirect |
| 205 | 205 | gopkg.in/yaml.v2 v2.4.0 // indirect |
| 206 | 206 | gopkg.in/yaml.v3 v3.0.1 // indirect |
| 207 | + gotest.tools/v3 v3.5.2 // indirect | |
| 207 | 208 | honnef.co/go/tools v0.6.1 // indirect |
| 208 | 209 | mvdan.cc/gofumpt v0.7.0 // indirect |
| 209 | 210 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect |
| @@ -204,6 +204,7 @@ require ( | |||
| 204 | gopkg.in/ini.v1 v1.67.0 // indirect | 204 | gopkg.in/ini.v1 v1.67.0 // indirect |
| 205 | gopkg.in/yaml.v2 v2.4.0 // indirect | 205 | gopkg.in/yaml.v2 v2.4.0 // indirect |
| 206 | gopkg.in/yaml.v3 v3.0.1 // indirect | 206 | gopkg.in/yaml.v3 v3.0.1 // indirect |
| 207 | + gotest.tools/v3 v3.5.2 // indirect | ||
| 207 | honnef.co/go/tools v0.6.1 // indirect | 208 | honnef.co/go/tools v0.6.1 // indirect |
| 208 | mvdan.cc/gofumpt v0.7.0 // indirect | 209 | mvdan.cc/gofumpt v0.7.0 // indirect |
| 209 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect | 210 | 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= | ||
| 610 | 610 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 611 | 611 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| 612 | 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 | 615 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= |
| 614 | 616 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= |
| 615 | 617 | 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, | ||
| 103 | 103 | func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { |
| 104 | 104 | query := ` |
| 105 | 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_at | |
| 106 | + a.published, a.updated, a.fetched_at, | |
| 107 | + COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0) | |
| 107 | 108 | FROM articles a |
| 108 | 109 | JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? |
| 109 | 110 | 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 | 130 | for rows.Next() { |
| 130 | 131 | a := &Article{} |
| 131 | 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 | 174 | return nil, err |
| 134 | 175 | } |
| 135 | 176 | 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_at | 106 | + a.published, a.updated, a.fetched_at, |
| 107 | + COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0) | ||
| 107 | FROM articles a | 108 | 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_url | 110 | 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, err | 174 | 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 | ||
| 22 | 22 | _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="#like-btn" hx-swap="outerHTML" id="like-btn" class="text-lg %s">♥ <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 | 25 | func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { |
| 37 | 26 | user := currentUser(r) |
| 38 | 27 | feedURL := r.URL.Query().Get("feed") |
| 28 | + status := r.URL.Query().Get("status") | |
| 39 | 29 | |
| 40 | 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 | 44 | if err != nil { |
| 43 | 45 | s.logger.Error("failed to list articles", "error", err) |
| 44 | 46 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| @@ -55,9 +57,10 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | ||
| 55 | 57 | "User": user, |
| 56 | 58 | "Articles": articles, |
| 57 | 59 | "FeedURL": feedURL, |
| 60 | + "Status": status, | |
| 58 | 61 | "Page": page, |
| 59 | 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 | 112 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 110 | 113 | 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 | 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 | 127 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 124 | 128 | 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 | 134 | 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">♥ <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">♥ <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 | return | 113 | 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 | return | 128 | 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 @@ | ||
| 3 | 3 | <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">← Back to articles</a> |
| 4 | 4 | |
| 5 | 5 | <article> |
| 6 | - <h1 class="text-3xl font-bold leading-tight"> | |
| 6 | + <h1 class="text-2xl font-bold leading-tight"> | |
| 7 | 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 | 8 | {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}} |
| 9 | 9 | </h1> |
| @@ -19,31 +19,41 @@ | ||
| 19 | 19 | {{end}} |
| 20 | 20 | </div> |
| 21 | 21 | |
| 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 | - ♥ <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"> | |
| 36 | 23 | <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}" |
| 37 | 24 | hx-target="#read-btn" hx-swap="outerHTML" |
| 38 | 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"> | |
| 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}} | |
| 41 | 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}}">♥</span> | |
| 40 | + {{.LikeCount}} | |
| 41 | + </button> | |
| 42 | + | |
| 43 | + {{if .Article.URL.Valid}} | |
| 43 | 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 | 47 | Original |
| 46 | 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 | 57 | </div> |
| 48 | 58 | |
| 49 | 59 | <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">← Back to articles</a> | 3 | <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">← 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 | - ♥ <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}}">♥</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 | Original | 47 | 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 @@ | ||
| 9 | 9 | </div> |
| 10 | 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 | 30 | <div id="article-list" class="space-y-3"> |
| 13 | 31 | {{range .Articles}} |
| 14 | 32 | {{template "article-card.html" .}} |
| 15 | 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 | 43 | {{end}} |
| 18 | 44 | </div> |
| 19 | 45 | |
| @@ -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 @@ | ||
| 39 | 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 | 40 | </div> |
| 41 | 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 | 63 | {{if .User}} |
| 43 | 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 | 65 | <div class="mb-8 px-3"> |
| @@ -158,6 +179,10 @@ | ||
| 158 | 179 | <span class="theme-text-dark">Light mode</span> |
| 159 | 180 | <span class="theme-text-light hidden">Dark mode</span> |
| 160 | 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 | 186 | </div> |
| 162 | 187 | </div> |
| 163 | 188 | <div class="text-right"> |
| @@ -182,12 +207,15 @@ | ||
| 182 | 207 | </div> |
| 183 | 208 | </div> |
| 184 | 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"> | |
| 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> | |
| 191 | 219 | <span class="text-xs text-spot-secondary">© {{now.Format "2006"}} Glean</span> |
| 192 | 220 | </div> |
| 193 | 221 | </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">© {{now.Format "2006"}} Glean</span> | 219 | <span class="text-xs text-spot-secondary">© {{now.Format "2006"}} Glean</span> |
| 192 | </div> | 220 | </div> |
| 193 | </div> | 221 | </div> |
modified
internal/tmpl/dashboard.html +7 -1 | @@ -34,7 +34,13 @@ | ||
| 34 | 34 | {{range .Articles}} |
| 35 | 35 | {{template "article-card.html" .}} |
| 36 | 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 | 44 | {{end}} |
| 39 | 45 | </div> |
| 40 | 46 | |
| @@ -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 @@ | ||
| 86 | 86 | </div> |
| 87 | 87 | </div> |
| 88 | 88 | {{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> | |
| 91 | 95 | </div> |
| 92 | 96 | {{end}} |
| 93 | 97 | </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 @@ | ||
| 12 | 12 | {{range .Articles}} |
| 13 | 13 | {{template "article-card.html" .}} |
| 14 | 14 | {{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> | |
| 18 | 21 | </div> |
| 19 | 22 | {{end}} |
| 20 | 23 | </div> |
| @@ -51,9 +54,12 @@ | ||
| 51 | 54 | {{template "annotation-card.html" .}} |
| 52 | 55 | </div> |
| 53 | 56 | {{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> | |
| 57 | 63 | </div> |
| 58 | 64 | {{end}} |
| 59 | 65 | </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 @@ | ||
| 1 | 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 | 3 | <div class="flex items-start justify-between gap-4"> |
| 4 | 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 | 9 | <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> |
| 7 | 10 | {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}} |
| 8 | 11 | {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} |
| @@ -14,6 +17,11 @@ | ||
| 14 | 17 | </div> |
| 15 | 18 | <div class="flex flex-col items-center gap-1 shrink-0"> |
| 16 | 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 | 25 | </div> |
| 18 | 26 | </div> |
| 19 | 27 | <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
internal/tmpl/trending.html +7 -1 | @@ -33,7 +33,13 @@ | ||
| 33 | 33 | </div> |
| 34 | 34 | </article> |
| 35 | 35 | {{else}} |
| 36 | - <div class="text-center text-spot-secondary py-12">No trending articles yet. Start liking and annotating!</div> | |
| 36 | + <div class="flex flex-col items-center justify-center py-16 text-center"> | |
| 37 | + <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4"> | |
| 38 | + <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="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> | |
| 39 | + </div> | |
| 40 | + <p class="text-sm text-spot-secondary mb-1">No trending articles yet</p> | |
| 41 | + <p class="text-xs text-spot-muted">Start liking and annotating to see trends.</p> | |
| 42 | + </div> | |
| 37 | 43 | {{end}} |
| 38 | 44 | </div> |
| 39 | 45 | |
| @@ -33,7 +33,13 @@ | |||
| 33 | </div> | 33 | </div> |
| 34 | </article> | 34 | </article> |
| 35 | {{else}} | 35 | {{else}} |
| 36 | - <div class="text-center text-spot-secondary py-12">No trending articles yet. Start liking and annotating!</div> | 36 | + <div class="flex flex-col items-center justify-center py-16 text-center"> |
| 37 | + <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4"> | ||
| 38 | + <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="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> | ||
| 39 | + </div> | ||
| 40 | + <p class="text-sm text-spot-secondary mb-1">No trending articles yet</p> | ||
| 41 | + <p class="text-xs text-spot-muted">Start liking and annotating to see trends.</p> | ||
| 42 | + </div> | ||
| 37 | {{end}} | 43 | {{end}} |
| 38 | </div> | 44 | </div> |
| 39 | 45 | ||
modified
main.go +1 -0 | @@ -75,6 +75,7 @@ func main() { | ||
| 75 | 75 | } |
| 76 | 76 | |
| 77 | 77 | sigCh := make(chan os.Signal, 1) |
| 78 | + signal.Reset(syscall.SIGINT, syscall.SIGTERM) | |
| 78 | 79 | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) |
| 79 | 80 | |
| 80 | 81 | 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() { |