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

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

Add next article navigationUnverified

Julien Robert committed 2026-04-26T20:55:16+02:00 Browse files
12453ff parent: 97314cb
modified internal/db/article.go +56 -0
@@ -3,6 +3,7 @@ package db
33 import (
44 "context"
55 "database/sql"
6+ "fmt"
67 "strings"
78 "time"
89 "unicode"
@@ -38,6 +39,9 @@ type Article struct {
3839 IsRead sql.NullBool
3940 LikeCount int
4041 HasLiked bool
42+ // NavSuffix holds the query string appended to article detail links to preserve
43+ // listing context (feed scope, liked) for next-article navigation.
44+ NavSuffix string
4145 }
4246
4347 type ReadState struct {
@@ -405,6 +409,58 @@ func (s *ArticleStore) CountNewArticles(ctx context.Context, userDID string, sin
405409 return count, err
406410 }
407411
412+func (s *ArticleStore) GetNextArticleID(ctx context.Context, userDID string, articleID int64, feedURL string, liked bool) (*int64, error) {
413+ var fromParts []string
414+ var whereParts []string
415+
416+ fromParts = append(fromParts, "articles.articles a")
417+
418+ if feedURL != "" {
419+ whereParts = append(whereParts, "a.feed_url = ?")
420+ } else {
421+ fromParts = append(fromParts, "JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?")
422+ }
423+
424+ if liked {
425+ fromParts = append(fromParts, "JOIN articles.likes l ON l.author_did = ? AND l.article_url = a.url")
426+ }
427+
428+ whereParts = append(whereParts, "a.id != ?")
429+
430+ fromClause := strings.Join(fromParts, " ")
431+ whereClause := strings.Join(whereParts, " AND ")
432+
433+ query := fmt.Sprintf(`
434+ WITH cur AS (SELECT published FROM articles.articles WHERE id = ?)
435+ SELECT a.id FROM %s, cur
436+ WHERE %s AND (
437+ (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) > (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END)
438+ OR (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) = (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END) AND a.published < cur.published
439+ OR (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) = (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END) AND a.published = cur.published AND a.id > ?
440+ )
441+ ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) ASC, a.published DESC, a.id ASC
442+ LIMIT 1
443+ `, fromClause, whereClause)
444+
445+ var args []any
446+ args = append(args, articleID)
447+ if feedURL != "" {
448+ args = append(args, feedURL)
449+ } else {
450+ args = append(args, userDID)
451+ }
452+ if liked {
453+ args = append(args, userDID)
454+ }
455+ args = append(args, articleID, articleID)
456+
457+ var next sql.NullInt64
458+ if err := s.db.QueryRowContext(ctx, query, args...).Scan(&next); err != nil {
459+ return nil, nil
460+ }
461+ return &next.Int64, nil
462+}
463+
408464 func escapeFTS5(query string) string {
409465 var b strings.Builder
410466 b.Grow(len(query))
@@ -3,6 +3,7 @@ package db
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6+ "fmt"
6 "strings"7 "strings"
7 "time"8 "time"
8 "unicode"9 "unicode"
@@ -38,6 +39,9 @@ type Article struct {
38 IsRead sql.NullBool39 IsRead sql.NullBool
39 LikeCount int40 LikeCount int
40 HasLiked bool41 HasLiked bool
42+ // NavSuffix holds the query string appended to article detail links to preserve
43+ // listing context (feed scope, liked) for next-article navigation.
44+ NavSuffix string
41 }45 }
42 46
43 type ReadState struct {47 type ReadState struct {
@@ -405,6 +409,58 @@ func (s *ArticleStore) CountNewArticles(ctx context.Context, userDID string, sin
405 return count, err409 return count, err
406 }410 }
407 411
412+func (s *ArticleStore) GetNextArticleID(ctx context.Context, userDID string, articleID int64, feedURL string, liked bool) (*int64, error) {
413+ var fromParts []string
414+ var whereParts []string
415+
416+ fromParts = append(fromParts, "articles.articles a")
417+
418+ if feedURL != "" {
419+ whereParts = append(whereParts, "a.feed_url = ?")
420+ } else {
421+ fromParts = append(fromParts, "JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?")
422+ }
423+
424+ if liked {
425+ fromParts = append(fromParts, "JOIN articles.likes l ON l.author_did = ? AND l.article_url = a.url")
426+ }
427+
428+ whereParts = append(whereParts, "a.id != ?")
429+
430+ fromClause := strings.Join(fromParts, " ")
431+ whereClause := strings.Join(whereParts, " AND ")
432+
433+ query := fmt.Sprintf(`
434+ WITH cur AS (SELECT published FROM articles.articles WHERE id = ?)
435+ SELECT a.id FROM %s, cur
436+ WHERE %s AND (
437+ (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) > (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END)
438+ OR (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) = (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END) AND a.published < cur.published
439+ OR (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) = (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END) AND a.published = cur.published AND a.id > ?
440+ )
441+ ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) ASC, a.published DESC, a.id ASC
442+ LIMIT 1
443+ `, fromClause, whereClause)
444+
445+ var args []any
446+ args = append(args, articleID)
447+ if feedURL != "" {
448+ args = append(args, feedURL)
449+ } else {
450+ args = append(args, userDID)
451+ }
452+ if liked {
453+ args = append(args, userDID)
454+ }
455+ args = append(args, articleID, articleID)
456+
457+ var next sql.NullInt64
458+ if err := s.db.QueryRowContext(ctx, query, args...).Scan(&next); err != nil {
459+ return nil, nil
460+ }
461+ return &next.Int64, nil
462+}
463+
408 func escapeFTS5(query string) string {464 func escapeFTS5(query string) string {
409 var b strings.Builder465 var b strings.Builder
410 b.Grow(len(query))466 b.Grow(len(query))
modified internal/server/annotations_handler.go +5 -0
@@ -48,6 +48,11 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) {
4848 likedPage.NextPage = likedPage.Page + 1
4949 }
5050
51+ navSuffix := buildNavSuffix("", true)
52+ for _, a := range articles {
53+ a.NavSuffix = navSuffix
54+ }
55+
5156 annotations, err := s.dbs.Articles.ListAnnotations(ctx, "", "", user.DID, limit+1, annotPage.Offset())
5257 if err != nil {
5358 s.logger.Warn("failed to list annotations", "error", err, "did", user.DID)
@@ -48,6 +48,11 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) {
48 likedPage.NextPage = likedPage.Page + 148 likedPage.NextPage = likedPage.Page + 1
49 }49 }
50 50
51+ navSuffix := buildNavSuffix("", true)
52+ for _, a := range articles {
53+ a.NavSuffix = navSuffix
54+ }
55+
51 annotations, err := s.dbs.Articles.ListAnnotations(ctx, "", "", user.DID, limit+1, annotPage.Offset())56 annotations, err := s.dbs.Articles.ListAnnotations(ctx, "", "", user.DID, limit+1, annotPage.Offset())
52 if err != nil {57 if err != nil {
53 s.logger.Warn("failed to list annotations", "error", err, "did", user.DID)58 s.logger.Warn("failed to list annotations", "error", err, "did", user.DID)
modified internal/server/articles_handler.go +30 -0
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "net/http"
8+ "net/url"
89 "strconv"
910 "time"
1011
@@ -79,6 +80,11 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
7980 articles = articles[:page.PageSize]
8081 }
8182
83+ navSuffix := buildNavSuffix(feedURL, false)
84+ for _, a := range articles {
85+ a.NavSuffix = navSuffix
86+ }
87+
8288 data := map[string]any{
8389 "User": user,
8490 "Articles": articles,
@@ -195,6 +201,14 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
195201 s.logger.Warn("failed to get feed", "error", err, "feed", article.FeedURL)
196202 }
197203
204+ fromFeedURL := r.URL.Query().Get("from_feed")
205+ navLiked := r.URL.Query().Get("liked") == "1"
206+
207+ nextID, err := s.dbs.Articles.GetNextArticleID(ctx, user.DID, id, fromFeedURL, navLiked)
208+ if err != nil {
209+ s.logger.Warn("failed to get next article", "error", err, "id", id)
210+ }
211+
198212 s.render(w, r, "article_detail.html", map[string]any{
199213 "User": user,
200214 "CurrentUserDID": user.DID,
@@ -204,6 +218,8 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
204218 "LikeCount": likeCount,
205219 "HasLiked": liked,
206220 "Annotations": annotations,
221+ "NextID": nextID,
222+ "NextSuffix": buildNavSuffix(fromFeedURL, navLiked),
207223 })
208224 }
209225
@@ -404,3 +420,17 @@ func (s *Server) handleFetchContent(w http.ResponseWriter, r *http.Request) {
404420 _, _ = fmt.Fprintf(w, `<div id="article-content" class="article-body">%s</div>`, cleaned)
405421 s.logger.Info("scraped article content", "id", id, "url", article.URL.String, "content_len", len(cleaned))
406422 }
423+
424+func buildNavSuffix(feedURL string, liked bool) string {
425+ v := url.Values{}
426+ if feedURL != "" {
427+ v.Set("from_feed", feedURL)
428+ }
429+ if liked {
430+ v.Set("liked", "1")
431+ }
432+ if len(v) == 0 {
433+ return ""
434+ }
435+ return "?" + v.Encode()
436+}
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "net/http"7 "net/http"
8+ "net/url"
8 "strconv"9 "strconv"
9 "time"10 "time"
10 11
@@ -79,6 +80,11 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
79 articles = articles[:page.PageSize]80 articles = articles[:page.PageSize]
80 }81 }
81 82
83+ navSuffix := buildNavSuffix(feedURL, false)
84+ for _, a := range articles {
85+ a.NavSuffix = navSuffix
86+ }
87+
82 data := map[string]any{88 data := map[string]any{
83 "User": user,89 "User": user,
84 "Articles": articles,90 "Articles": articles,
@@ -195,6 +201,14 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
195 s.logger.Warn("failed to get feed", "error", err, "feed", article.FeedURL)201 s.logger.Warn("failed to get feed", "error", err, "feed", article.FeedURL)
196 }202 }
197 203
204+ fromFeedURL := r.URL.Query().Get("from_feed")
205+ navLiked := r.URL.Query().Get("liked") == "1"
206+
207+ nextID, err := s.dbs.Articles.GetNextArticleID(ctx, user.DID, id, fromFeedURL, navLiked)
208+ if err != nil {
209+ s.logger.Warn("failed to get next article", "error", err, "id", id)
210+ }
211+
198 s.render(w, r, "article_detail.html", map[string]any{212 s.render(w, r, "article_detail.html", map[string]any{
199 "User": user,213 "User": user,
200 "CurrentUserDID": user.DID,214 "CurrentUserDID": user.DID,
@@ -204,6 +218,8 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
204 "LikeCount": likeCount,218 "LikeCount": likeCount,
205 "HasLiked": liked,219 "HasLiked": liked,
206 "Annotations": annotations,220 "Annotations": annotations,
221+ "NextID": nextID,
222+ "NextSuffix": buildNavSuffix(fromFeedURL, navLiked),
207 })223 })
208 }224 }
209 225
@@ -404,3 +420,17 @@ func (s *Server) handleFetchContent(w http.ResponseWriter, r *http.Request) {
404 _, _ = fmt.Fprintf(w, `<div id="article-content" class="article-body">%s</div>`, cleaned)420 _, _ = fmt.Fprintf(w, `<div id="article-content" class="article-body">%s</div>`, cleaned)
405 s.logger.Info("scraped article content", "id", id, "url", article.URL.String, "content_len", len(cleaned))421 s.logger.Info("scraped article content", "id", id, "url", article.URL.String, "content_len", len(cleaned))
406 }422 }
423+
424+func buildNavSuffix(feedURL string, liked bool) string {
425+ v := url.Values{}
426+ if feedURL != "" {
427+ v.Set("from_feed", feedURL)
428+ }
429+ if liked {
430+ v.Set("liked", "1")
431+ }
432+ if len(v) == 0 {
433+ return ""
434+ }
435+ return "?" + v.Encode()
436+}
modified internal/tmpl/article_detail.html +31 -8
@@ -1,9 +1,18 @@
11 {{define "article_detail.html"}}
22 <div class="max-w-3xl mx-auto">
3- <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mb-6 inline-flex items-center gap-1.5 transition">
4- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
5- Back
6- </a>
3+ <div class="flex items-center justify-between mb-6">
4+ <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
5+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
6+ Back
7+ </a>
8+ {{if .NextID}}
9+ <a href="/articles/{{.NextID}}{{.NextSuffix}}" id="next-link"
10+ class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
11+ Next
12+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
13+ </a>
14+ {{end}}
15+ </div>
716
817 <article>
918 <h1 class="text-2xl font-bold leading-tight">
@@ -138,10 +147,19 @@
138147 </div>
139148 </section>
140149
141- <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mt-8 inline-flex items-center gap-1.5 transition">
142- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
143- Back
144- </a>
150+ <div class="flex items-center justify-between mt-8">
151+ <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
152+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
153+ Back
154+ </a>
155+ {{if .NextID}}
156+ <a href="/articles/{{.NextID}}{{.NextSuffix}}"
157+ class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
158+ Next
159+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
160+ </a>
161+ {{end}}
162+ </div>
145163 </div>
146164
147165 <script>
@@ -259,6 +277,11 @@
259277 } else if (e.key === 'o') {
260278 var origLink = document.querySelector('a[target="_blank"]');
261279 if (origLink && origLink.href) window.open(origLink.href, '_blank');
280+ } else if (e.key === 'ArrowRight') {
281+ var nextLink = document.getElementById('next-link');
282+ if (nextLink) nextLink.click();
283+ } else if (e.key === 'ArrowLeft') {
284+ history.back();
262285 }
263286 });
264287 })();
@@ -1,9 +1,18 @@
1 {{define "article_detail.html"}}1 {{define "article_detail.html"}}
2 <div class="max-w-3xl mx-auto">2 <div class="max-w-3xl mx-auto">
3- <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mb-6 inline-flex items-center gap-1.5 transition">3+ <div class="flex items-center justify-between mb-6">
4- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>4+ <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
5- Back5+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
6- </a>6+ Back
7+ </a>
8+ {{if .NextID}}
9+ <a href="/articles/{{.NextID}}{{.NextSuffix}}" id="next-link"
10+ class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
11+ Next
12+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
13+ </a>
14+ {{end}}
15+ </div>
7 16
8 <article>17 <article>
9 <h1 class="text-2xl font-bold leading-tight">18 <h1 class="text-2xl font-bold leading-tight">
@@ -138,10 +147,19 @@
138 </div>147 </div>
139 </section>148 </section>
140 149
141- <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mt-8 inline-flex items-center gap-1.5 transition">150+ <div class="flex items-center justify-between mt-8">
142- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>151+ <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
143- Back152+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
144- </a>153+ Back
154+ </a>
155+ {{if .NextID}}
156+ <a href="/articles/{{.NextID}}{{.NextSuffix}}"
157+ class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
158+ Next
159+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
160+ </a>
161+ {{end}}
162+ </div>
145 </div>163 </div>
146 164
147 <script>165 <script>
@@ -259,6 +277,11 @@
259 } else if (e.key === 'o') {277 } else if (e.key === 'o') {
260 var origLink = document.querySelector('a[target="_blank"]');278 var origLink = document.querySelector('a[target="_blank"]');
261 if (origLink && origLink.href) window.open(origLink.href, '_blank');279 if (origLink && origLink.href) window.open(origLink.href, '_blank');
280+ } else if (e.key === 'ArrowRight') {
281+ var nextLink = document.getElementById('next-link');
282+ if (nextLink) nextLink.click();
283+ } else if (e.key === 'ArrowLeft') {
284+ history.back();
262 }285 }
263 });286 });
264 })();287 })();
modified internal/tmpl/partials/article-card.html +1 -1
@@ -4,7 +4,7 @@
44 <div class="min-w-0 flex-1">
55 <div class="flex items-center gap-2">
66 {{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>
7+ <a href="/articles/{{.ID}}{{.NavSuffix}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a>
88 </div>
99 <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2">
1010 {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-4 h-4"}}
@@ -4,7 +4,7 @@
4 <div class="min-w-0 flex-1">4 <div class="min-w-0 flex-1">
5 <div class="flex items-center gap-2">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}}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>7+ <a href="/articles/{{.ID}}{{.NavSuffix}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a>
8 </div>8 </div>
9 <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">
10 {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-4 h-4"}}10 {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-4 h-4"}}