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:
+56
-6
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
thumbSize = 600
|
thumbSize = 600
|
||||||
mediumMaxSize = 1600
|
mediumMaxSize = 1600
|
||||||
|
pageSize = 60
|
||||||
)
|
)
|
||||||
|
|
||||||
// PhotoMeta is the structure of a .toml sidecar file.
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
for _, p := range photos {
|
for _, p := range photos {
|
||||||
@@ -290,10 +292,53 @@ func generateMedium(src, dst string, maxDim int) error {
|
|||||||
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
|
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderIndex writes public/index.html.
|
// renderIndexPages writes public/index.html and public/page/N/index.html
|
||||||
func (g *Generator) renderIndex(photos []Photo) error {
|
// files, chunking photos into pageSize slices for progressive pagination.
|
||||||
return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{
|
func (g *Generator) renderIndexPages(photos []Photo) error {
|
||||||
"Photos": photos,
|
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,
|
"BaseURL": g.baseURL,
|
||||||
"Year": time.Now().Year(),
|
"Year": time.Now().Year(),
|
||||||
"Author": g.author,
|
"Author": g.author,
|
||||||
@@ -304,7 +349,12 @@ func (g *Generator) renderIndex(photos []Photo) error {
|
|||||||
"FontsURL": g.fontsURL,
|
"FontsURL": g.fontsURL,
|
||||||
"SerifFamily": g.serifFamily,
|
"SerifFamily": g.serifFamily,
|
||||||
"MlFamily": g.mlFamily,
|
"MlFamily": g.mlFamily,
|
||||||
})
|
}
|
||||||
|
if err := g.render("index.html", dst, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderPhotoPage writes public/photo/<slug>/index.html.
|
// renderPhotoPage writes public/photo/<slug>/index.html.
|
||||||
|
|||||||
@@ -163,6 +163,30 @@ body {
|
|||||||
transform: scale(1.05);
|
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 ─────────────────────────────────────── */
|
/* ── Single photo page ─────────────────────────────────────── */
|
||||||
|
|
||||||
.photo-page {
|
.photo-page {
|
||||||
|
|||||||
+46
-1
@@ -18,7 +18,7 @@
|
|||||||
<div class="pinfo">
|
<div class="pinfo">
|
||||||
<h1>{{.Author}}</h1>
|
<h1>{{.Author}}</h1>
|
||||||
<p class="handle">{{.Handle}}</p>
|
<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>
|
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||||
<a href="/feed.xml" class="rss-a">
|
<a href="/feed.xml" class="rss-a">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
||||||
@@ -41,11 +41,56 @@
|
|||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||||
</footer>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user