A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+361 -54
+1
go.mod
··· 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
+2
go.sum
··· 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=
+43 -2
internal/db/article.go
··· 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 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)
+163
internal/db/article_test.go
··· 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 + }
+20 -15
internal/server/articles_handler.go
··· 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">&#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 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 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 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 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) {
+28 -18
internal/tmpl/article_detail.html
··· 3 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 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 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 - &#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"> 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}}">&#9829;</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">
+27 -1
internal/tmpl/articles.html
··· 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
+34 -6
internal/tmpl/base.html
··· 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 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 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">&copy; {{now.Format "2006"}} Glean</span> 192 220 </div> 193 221 </div>
+7 -1
internal/tmpl/dashboard.html
··· 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
+6 -2
internal/tmpl/feeds.html
··· 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>
+12 -6
internal/tmpl/library.html
··· 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 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>
+10 -2
internal/tmpl/partials/article-card.html
··· 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 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">
+7 -1
internal/tmpl/trending.html
··· 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
+1
main.go
··· 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() {