Add next article navigationUnverified
12453ff parent: 97314cb modified
internal/db/article.go +56 -0 | @@ -3,6 +3,7 @@ package db | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | + "fmt" | |
| 6 | 7 | "strings" |
| 7 | 8 | "time" |
| 8 | 9 | "unicode" |
| @@ -38,6 +39,9 @@ type Article struct { | ||
| 38 | 39 | IsRead sql.NullBool |
| 39 | 40 | LikeCount int |
| 40 | 41 | 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 | 47 | type ReadState struct { |
| @@ -405,6 +409,58 @@ func (s *ArticleStore) CountNewArticles(ctx context.Context, userDID string, sin | ||
| 405 | 409 | 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 | 464 | func escapeFTS5(query string) string { |
| 409 | 465 | var b strings.Builder |
| 410 | 466 | 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.NullBool | 39 | IsRead sql.NullBool |
| 39 | LikeCount int | 40 | LikeCount int |
| 40 | HasLiked bool | 41 | 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, err | 409 | 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.Builder | 465 | 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) { | ||
| 48 | 48 | 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 | 56 | annotations, err := s.dbs.Articles.ListAnnotations(ctx, "", "", user.DID, limit+1, annotPage.Offset()) |
| 52 | 57 | if err != nil { |
| 53 | 58 | 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 + 1 | 48 | 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 ( | ||
| 5 | 5 | "errors" |
| 6 | 6 | "fmt" |
| 7 | 7 | "net/http" |
| 8 | + "net/url" | |
| 8 | 9 | "strconv" |
| 9 | 10 | "time" |
| 10 | 11 | |
| @@ -79,6 +80,11 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | ||
| 79 | 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 | 88 | data := map[string]any{ |
| 83 | 89 | "User": user, |
| 84 | 90 | "Articles": articles, |
| @@ -195,6 +201,14 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | ||
| 195 | 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 | 212 | s.render(w, r, "article_detail.html", map[string]any{ |
| 199 | 213 | "User": user, |
| 200 | 214 | "CurrentUserDID": user.DID, |
| @@ -204,6 +218,8 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | ||
| 204 | 218 | "LikeCount": likeCount, |
| 205 | 219 | "HasLiked": liked, |
| 206 | 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 | 420 | _, _ = fmt.Fprintf(w, `<div id="article-content" class="article-body">%s</div>`, cleaned) |
| 405 | 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 | +} | |
| @@ -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 @@ | ||
| 1 | 1 | {{define "article_detail.html"}} |
| 2 | 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"> | |
| 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> | |
| 7 | 16 | |
| 8 | 17 | <article> |
| 9 | 18 | <h1 class="text-2xl font-bold leading-tight"> |
| @@ -138,10 +147,19 @@ | ||
| 138 | 147 | </div> |
| 139 | 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"> | |
| 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> | |
| 145 | 163 | </div> |
| 146 | 164 | |
| 147 | 165 | <script> |
| @@ -259,6 +277,11 @@ | ||
| 259 | 277 | } else if (e.key === 'o') { |
| 260 | 278 | var origLink = document.querySelector('a[target="_blank"]'); |
| 261 | 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 | })(); |
| @@ -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 | - Back | 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 | - </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 | - Back | 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> |
| 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 @@ | ||
| 4 | 4 | <div class="min-w-0 flex-1"> |
| 5 | 5 | <div class="flex items-center gap-2"> |
| 6 | 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 | 8 | </div> |
| 9 | 9 | <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> |
| 10 | 10 | {{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"}} |