feat: add navigation for previous and next posts
R
Rüdiger Küpper
<rpr@9it.de>
committete am 19.02.2026 02:21
d1bf374fa130243a8a3a18ff4545bbdd270e9448
| @@ -60,25 +60,33 @@ func Generate(cfg *config.Config, posts []*content.Post, pages []*content.Page) | ||
| 60 | 60 | dir := filepath.Join(out, "page", fmt.Sprintf("%d", page)) |
| 61 | 61 | os.MkdirAll(dir, 0755) |
| 62 | 62 | pageCopy := data // capture for closure |
| 63 | 63 | writeTemplate(filepath.Join(dir, "index.html"), func(f *os.File) error { |
| 64 | 64 | return templates.RenderHome(f, pageCopy) |
| 65 | 65 | }) |
| 66 | 66 | } |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | 69 | // 2. Individual posts |
| 70 | - for _, post := range posts { |
|
| 70 | + for i, post := range posts { |
|
| 71 | 71 | dir := filepath.Join(out, "posts", post.Slug) |
| 72 | 72 | os.MkdirAll(dir, 0755) |
| 73 | + data := templates.PostData{Site: site, Post: post} |
|
| 74 | +// Posts are sorted newest first: index 0 = newest |
|
| 75 | + if i > 0 { |
|
| 76 | +data.PrevPost = posts[i-1] // newer |
|
| 77 | +} |
|
| 78 | + if i < len(posts)-1 { |
|
| 79 | +data.NextPost = posts[i+1] // older |
|
| 80 | +} |
|
| 73 | 81 | writeTemplate(filepath.Join(dir, "index.html"), func(f *os.File) error { |
| 74 | - return templates.RenderPost(f, templates.PostData{Site: site, Post: post}) |
|
| 82 | +return templates.RenderPost(f, data) |
|
| 75 | 83 | }) |
| 76 | 84 | } |
| 77 | 85 | |
| 78 | 86 | // 3. Tags index |
| 79 | 87 | tagMap := content.CollectTags(posts) |
| 80 | 88 | tagCounts := make(map[string]int) |
| 81 | 89 | for tag, tagPosts := range tagMap { |
| 82 | 90 | tagCounts[tag] = len(tagPosts) |
| 83 | 91 | } |
| 84 | 92 | os.MkdirAll(filepath.Join(out, "tags"), 0755) |
| @@ -14,22 +14,24 @@ import ( | ||
| 14 | 14 | ) |
| 15 | 15 | |
| 16 | 16 | func Start(cfg *config.Config, posts []*content.Post, pages []*content.Page) { |
| 17 | 17 | if err := templates.Init(); err != nil { |
| 18 | 18 | log.Fatalf("Failed to init templates: %v", err) |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | 21 | tagMap := content.CollectTags(posts) |
| 22 | 22 | catMap := content.CollectCategories(posts) |
| 23 | 23 | postMap := make(map[string]*content.Post) |
| 24 | - for _, p := range posts { |
|
| 24 | +postIndex := make(map[string]int) |
|
| 25 | + for i, p := range posts { |
|
| 25 | 26 | postMap[p.Slug] = p |
| 27 | +postIndex[p.Slug] = i |
|
| 26 | 28 | } |
| 27 | 29 | pageMap := make(map[string]*content.Page) |
| 28 | 30 | for _, p := range pages { |
| 29 | 31 | pageMap[p.Slug] = p |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | site := templates.SiteData{ |
| 33 | 35 | Title: cfg.Title, |
| 34 | 36 | Description: cfg.Description, |
| 35 | 37 | Author: cfg.Author, |
| @@ -167,20 +169,28 @@ func Start(cfg *config.Config, posts []*content.Post, pages []*content.Page) { | ||
| 167 | 169 | // Individual posts |
| 168 | 170 | mux.HandleFunc("/posts/", func(w http.ResponseWriter, r *http.Request) { |
| 169 | 171 | slug := strings.TrimPrefix(r.URL.Path, "/posts/") |
| 170 | 172 | slug = strings.TrimSuffix(slug, "/") |
| 171 | 173 | post, ok := postMap[slug] |
| 172 | 174 | if !ok { |
| 173 | 175 | http.NotFound(w, r) |
| 174 | 176 | return |
| 175 | 177 | } |
| 176 | 178 | data := templates.PostData{Site: site, Post: post} |
| 179 | +// Posts are sorted newest first: index 0 = newest |
|
| 180 | +idx := postIndex[slug] |
|
| 181 | + if idx > 0 { |
|
| 182 | +data.PrevPost = posts[idx-1] // newer |
|
| 183 | +} |
|
| 184 | + if idx < len(posts)-1 { |
|
| 185 | +data.NextPost = posts[idx+1] // older |
|
| 186 | +} |
|
| 177 | 187 | if err := templates.RenderPost(w, data); err != nil { |
| 178 | 188 | log.Printf("Error rendering post: %v", err) |
| 179 | 189 | http.Error(w, "Internal Server Error", 500) |
| 180 | 190 | } |
| 181 | 191 | }) |
| 182 | 192 | |
| 183 | 193 | // Static pages (sites) |
| 184 | 194 | mux.HandleFunc("/sites/", func(w http.ResponseWriter, r *http.Request) { |
| 185 | 195 | slug := strings.TrimPrefix(r.URL.Path, "/sites/") |
| 186 | 196 | slug = strings.TrimSuffix(slug, "/") |
| @@ -17,15 +17,33 @@ | ||
| 17 | 17 | </div> |
| 18 | 18 | {{end}} |
| 19 | 19 | {{if .Post.TOC}} |
| 20 | 20 | <nav class="toc" id="toc"> |
| 21 | 21 | <h3>Inhaltsverzeichnis</h3> |
| 22 | 22 | </nav> |
| 23 | 23 | {{end}} |
| 24 | 24 | <div class="post-content"> |
| 25 | 25 | {{safeHTML .Post.HTMLContent}} |
| 26 | 26 | </div> |
| 27 | +<nav class="post-nav"> |
|
| 28 | + {{if .PrevPost}} |
|
| 29 | + <a href="/posts/{{.PrevPost.Slug}}/" class="post-nav-link post-nav-prev"> |
|
| 30 | +<span class="post-nav-label">← Neuerer Artikel</span> |
|
| 31 | + <span class="post-nav-title">{{.PrevPost.Title}}</span> |
|
| 32 | +</a> |
|
| 33 | + {{else}} |
|
| 34 | +<span class="post-nav-link post-nav-prev disabled"></span> |
|
| 35 | + {{end}} |
|
| 36 | + {{if .NextPost}} |
|
| 37 | + <a href="/posts/{{.NextPost.Slug}}/" class="post-nav-link post-nav-next"> |
|
| 38 | +<span class="post-nav-label">Älterer Artikel →</span> |
|
| 39 | + <span class="post-nav-title">{{.NextPost.Title}}</span> |
|
| 40 | +</a> |
|
| 41 | + {{else}} |
|
| 42 | +<span class="post-nav-link post-nav-next disabled"></span> |
|
| 43 | + {{end}} |
|
| 44 | +</nav> |
|
| 27 | 45 | <div class="post-footer"> |
| 28 | 46 | <a href="/" class="back-link">← Zurück zur Startseite</a> |
| 29 | 47 | </div> |
| 30 | 48 | </article> |
| 31 | 49 | {{end}} |
| @@ -24,22 +24,24 @@ type SiteData struct { | ||
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | type HomeData struct { |
| 27 | 27 | Site SiteData |
| 28 | 28 | Posts []*content.Post |
| 29 | 29 | Page int // current page (1-based) |
| 30 | 30 | TotalPages int |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | type PostData struct { |
| 34 | -Site SiteData |
|
| 35 | -Post *content.Post |
|
| 34 | +Site SiteData |
|
| 35 | +Post *content.Post |
|
| 36 | +PrevPost *content.Post // newer post (nil if this is the newest) |
|
| 37 | +NextPost *content.Post // older post (nil if this is the oldest) |
|
| 36 | 38 | } |
| 37 | 39 | |
| 38 | 40 | type TagsData struct { |
| 39 | 41 | Site SiteData |
| 40 | 42 | Tags map[string]int |
| 41 | 43 | } |
| 42 | 44 | |
| 43 | 45 | type TagData struct { |
| 44 | 46 | Site SiteData |
| 45 | 47 | Tag string |
| @@ -894,26 +894,92 @@ body { | ||
| 894 | 894 | font-weight: 600; |
| 895 | 895 | color: var(--accent); |
| 896 | 896 | background: var(--bg-card); |
| 897 | 897 | } |
| 898 | 898 | |
| 899 | 899 | .post-content tr:hover td { |
| 900 | 900 | background: var(--bg-card); |
| 901 | 901 | } |
| 902 | 902 | |
| 903 | 903 | /* === Post Footer === */ |
| 904 | -.post-footer { |
|
| 904 | +/* === Post Navigation (Prev/Next) === */ |
|
| 905 | +.post-nav { |
|
| 906 | +display: flex; |
|
| 907 | +gap: 1.5rem; |
|
| 905 | 908 | margin-top: 3rem; |
| 906 | 909 | padding-top: 1.5rem; |
| 907 | 910 | border-top: 1px solid var(--border); |
| 908 | 911 | } |
| 909 | 912 | |
| 913 | +.post-nav-link { |
|
| 914 | +flex: 1; |
|
| 915 | +display: flex; |
|
| 916 | +flex-direction: column; |
|
| 917 | +gap: 0.3rem; |
|
| 918 | +padding: 1rem; |
|
| 919 | +border-radius: 10px; |
|
| 920 | +background: var(--bg-card); |
|
| 921 | +border: 1px solid var(--border); |
|
| 922 | +text-decoration: none; |
|
| 923 | +transition: all 0.2s ease; |
|
| 924 | +} |
|
| 925 | + | |
| 926 | +.post-nav-link:hover { |
|
| 927 | +border-color: var(--accent); |
|
| 928 | +background: var(--bg-card-hover); |
|
| 929 | +box-shadow: 0 0 16px var(--accent-glow); |
|
| 930 | +} |
|
| 931 | + | |
| 932 | +.post-nav-link.disabled { |
|
| 933 | +visibility: hidden; |
|
| 934 | +} |
|
| 935 | + | |
| 936 | +.post-nav-prev { |
|
| 937 | +align-items: flex-start; |
|
| 938 | +} |
|
| 939 | + | |
| 940 | +.post-nav-next { |
|
| 941 | +align-items: flex-end; |
|
| 942 | +text-align: right; |
|
| 943 | +} |
|
| 944 | + | |
| 945 | +.post-nav-label { |
|
| 946 | +font-family: 'JetBrains Mono', monospace; |
|
| 947 | +font-size: 0.75rem; |
|
| 948 | +color: var(--text-muted); |
|
| 949 | +text-transform: uppercase; |
|
| 950 | +letter-spacing: 0.05em; |
|
| 951 | +} |
|
| 952 | + | |
| 953 | +.post-nav-title { |
|
| 954 | +font-size: 0.9rem; |
|
| 955 | +font-weight: 600; |
|
| 956 | +color: var(--accent); |
|
| 957 | +line-height: 1.3; |
|
| 958 | +} |
|
| 959 | + | |
| 960 | +@media (max-width: 640px) { |
|
| 961 | + .post-nav { |
|
| 962 | +flex-direction: column; |
|
| 963 | +gap: 0.75rem; |
|
| 964 | +} |
|
| 965 | + .post-nav-next { |
|
| 966 | +align-items: flex-start; |
|
| 967 | +text-align: left; |
|
| 968 | +} |
|
| 969 | +} |
|
| 970 | + | |
| 971 | +.post-footer { |
|
| 972 | +margin-top: 1.5rem; |
|
| 973 | +padding-top: 1rem; |
|
| 974 | +} |
|
| 975 | + | |
| 910 | 976 | .back-link { |
| 911 | 977 | font-family: 'JetBrains Mono', monospace; |
| 912 | 978 | font-size: 0.85rem; |
| 913 | 979 | color: var(--accent); |
| 914 | 980 | text-decoration: none; |
| 915 | 981 | transition: all 0.2s ease; |
| 916 | 982 | } |
| 917 | 983 | |
| 918 | 984 | .back-link:hover { |
| 919 | 985 | text-shadow: 0 0 12px var(--accent-glow-strong); |