Paginate the gallery grid with 60 photos per page

Emit /page/N/ pages alongside index.html and render a pager at the
bottom of each page. Ships as a plain link that navigates without JS,
and an IntersectionObserver enhancement that fetches the next page and
appends tiles for an infinite-scroll feel.
This commit is contained in:
2026-07-02 22:20:28 +02:00
parent 613906cd0c
commit 856439f3fd
3 changed files with 136 additions and 17 deletions
+66 -16
View File
@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -23,6 +24,7 @@ import (
const (
thumbSize = 600
mediumMaxSize = 1600
pageSize = 60
)
// PhotoMeta is the structure of a .toml sidecar file.
@@ -103,7 +105,7 @@ func (g *Generator) Build() error {
}
}
if err := g.renderIndex(photos); err != nil {
if err := g.renderIndexPages(photos); err != nil {
return err
}
for _, p := range photos {
@@ -290,21 +292,69 @@ func generateMedium(src, dst string, maxDim int) error {
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
}
// renderIndex writes public/index.html.
func (g *Generator) renderIndex(photos []Photo) error {
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
"Photos": photos,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
})
// renderIndexPages writes public/index.html and public/page/N/index.html
// files, chunking photos into pageSize slices for progressive pagination.
func (g *Generator) renderIndexPages(photos []Photo) error {
total := len(photos)
totalPages := (total + pageSize - 1) / pageSize
if totalPages == 0 {
totalPages = 1
}
for i := 0; i < totalPages; i++ {
start := i * pageSize
end := start + pageSize
if end > total {
end = total
}
slice := photos[start:end]
dst := filepath.Join(g.outputDir, "index.html")
if i > 0 {
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
}
prev := ""
next := ""
switch i {
case 0:
// no prev
case 1:
prev = "/"
default:
prev = "/page/" + strconv.Itoa(i) + "/"
}
if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/"
}
data := map[string]any{
"Photos": slice,
"Total": total,
"Page": i + 1,
"TotalPages": totalPages,
"PrevURL": prev,
"NextURL": next,
"BaseURL": g.baseURL,
"Year": time.Now().Year(),
"Author": g.author,
"Handle": g.handle,
"Bio": g.bio,
"BioAlt": g.bioAlt,
"Initial": g.initial(),
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily,
}
if err := g.render("index.html", dst, data); err != nil {
return err
}
}
return nil
}
// renderPhotoPage writes public/photo/<slug>/index.html.
+24
View File
@@ -163,6 +163,30 @@ body {
transform: scale(1.05);
}
/* ── Pager ─────────────────────────────────────────────────── */
.pager {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 20px 8px;
font-family: monospace;
font-size: 13px;
letter-spacing: 0.04em;
}
.pager a {
color: var(--aqua);
text-decoration: none;
}
.pager a:hover {
color: var(--fg2);
}
.pager-newer { margin-right: auto; }
.pager-older { margin-left: auto; }
/* ── Single photo page ─────────────────────────────────────── */
.photo-page {
+46 -1
View File
@@ -18,7 +18,7 @@
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{len .Photos}}</strong> posts</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
@@ -41,11 +41,56 @@
</a>
{{end}}
</div>
{{if or .PrevURL .NextURL}}
<nav class="pager">
{{if .PrevURL}}<a class="pager-newer" href="{{.PrevURL}}">← Newer</a>{{end}}
{{if .NextURL}}<a class="pager-older" href="{{.NextURL}}" data-load-more>Older →</a>{{end}}
</nav>
{{end}}
</div>
</main>
<footer class="site-footer">
© 2000{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script>
(function () {
const btn = document.querySelector('[data-load-more]');
if (!btn) return;
const grid = document.querySelector('.pgrid');
let loading = false;
async function loadMore(url) {
if (loading) return;
loading = true;
try {
const res = await fetch(url);
if (!res.ok) return;
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('.pgrid > a').forEach(t => grid.appendChild(t));
const nextBtn = doc.querySelector('[data-load-more]');
if (nextBtn) btn.href = nextBtn.getAttribute('href');
else btn.remove();
} finally {
loading = false;
}
}
btn.addEventListener('click', (e) => {
e.preventDefault();
loadMore(btn.href);
});
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && document.body.contains(btn)) {
loadMore(btn.href);
}
}, { rootMargin: '400px' });
io.observe(btn);
})();
</script>
</body>
</html>