Make the timeline the default view, paginated and configurable

The day-grouped timeline is now the site root (/, /page/N/); the flat
square grid moves to /grid/ (/grid/page/N/). Both are linked from the
profile header, separated by a divider.

Timeline pages pack whole day-groups until they reach the page size,
never splitting a day across a page boundary, so appended pages never
repeat a date heading — the infinite-scroll handler relies on this.

Page size is configurable via -page-size (default 60), threaded through
the Generator and exposed as services.photogallery.pageSize in the
NixOS module.
This commit is contained in:
2026-08-20 00:11:08 +02:00
parent 9b6fcb482b
commit 4ddf5adf24
8 changed files with 193 additions and 45 deletions
+1
View File
@@ -1,5 +1,6 @@
/content/ /content/
/public/ /public/
/.demo/
# Anchored: unanchored these would also match the Kotlin package directory # Anchored: unanchored these would also match the Kotlin package directory
# android/app/src/main/java/ws/inflo/photogallery/ and any nested "result". # android/app/src/main/java/ws/inflo/photogallery/ and any nested "result".
/photogallery /photogallery
+5 -3
View File
@@ -45,9 +45,10 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first
- Gruvbox dark hard colour scheme - Gruvbox dark hard colour scheme
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url` - Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
- Manjari for Malayalam text (bio line), via `-ml-family` - Manjari for Malayalam text (bio line), via `-ml-family`
- 3-column square grid → 2-column on mobile, 60 photos per page (`/`, `/page/2/`, …) - Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via `-page-size` (default 60; NixOS `services.photogallery.pageSize`):
- **Timeline is the site root** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this).
- **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header.
- Single photo page with sidebar and newer/older navigation - Single photo page with sidebar and newer/older navigation
- Timeline page (`/timeline/`) groups photos by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the same square grid. Linked from the profile header; `groupByDay` in `generator.go` collapses the newest-first photo list into consecutive same-day runs, so it is not paginated.
### Photo page navigation ### Photo page navigation
@@ -130,8 +131,9 @@ nix build # succeeds
| `server.go` | Upload API + optional static file serving | | `server.go` | Upload API + optional static file serving |
| `watcher.go` | fsnotify watcher with 500ms debounce | | `watcher.go` | fsnotify watcher with 500ms debounce |
| `assets.go` | `//go:embed` for templates and static | | `assets.go` | `//go:embed` for templates and static |
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
| `templates/photo.html` | Single photo page, neighbour links and prefetch | | `templates/photo.html` | Single photo page, neighbour links and prefetch |
| `templates/timeline.html` | Timeline page, photos grouped by day |
| `static/photo-nav.js` | Arrow-key and swipe navigation | | `static/photo-nav.js` | Arrow-key and swipe navigation |
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) | | `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
| `flake.nix` | `buildGoModule` package + devShell + nixosModules | | `flake.nix` | `buildGoModule` package + devShell + nixosModules |
+108 -38
View File
@@ -22,9 +22,9 @@ import (
) )
const ( const (
thumbSize = 600 thumbSize = 600
mediumMaxSize = 1600 mediumMaxSize = 1600
pageSize = 60 defaultPageSize = 60
) )
// PhotoMeta is the structure of a .toml sidecar file. // PhotoMeta is the structure of a .toml sidecar file.
@@ -62,12 +62,16 @@ type Generator struct {
fontsURL string fontsURL string
serifFamily string serifFamily string
mlFamily string mlFamily string
pageSize int
mu sync.Mutex mu sync.Mutex
tmpl *template.Template tmpl *template.Template
} }
func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string) *Generator { func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
if pageSize < 1 {
pageSize = defaultPageSize
}
return &Generator{ return &Generator{
contentDir: contentDir, contentDir: contentDir,
outputDir: outputDir, outputDir: outputDir,
@@ -79,6 +83,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
fontsURL: fontsURL, fontsURL: fontsURL,
serifFamily: serifFamily, serifFamily: serifFamily,
mlFamily: mlFamily, mlFamily: mlFamily,
pageSize: pageSize,
tmpl: tmpl, tmpl: tmpl,
} }
} }
@@ -101,7 +106,7 @@ func (g *Generator) Build() error {
return err return err
} }
for _, d := range []string{"images", "thumbs", "medium", "photo", "static"} { for _, d := range []string{"images", "thumbs", "medium", "photo", "grid", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err return err
} }
@@ -113,7 +118,10 @@ func (g *Generator) Build() error {
} }
} }
if err := g.renderIndexPages(photos); err != nil { if err := g.renderTimelinePages(photos); err != nil {
return err
}
if err := g.renderGridPages(photos); err != nil {
return err return err
} }
for i := range photos { for i := range photos {
@@ -128,9 +136,6 @@ func (g *Generator) Build() error {
return err return err
} }
} }
if err := g.renderTimeline(photos); err != nil {
return err
}
if err := g.renderFeed(photos); err != nil { if err := g.renderFeed(photos); err != nil {
return err return err
} }
@@ -310,26 +315,29 @@ 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})
} }
// renderIndexPages writes public/index.html and public/page/N/index.html // renderGridPages writes the paginated square-grid view under public/grid/:
// files, chunking photos into pageSize slices for progressive pagination. // grid/index.html and grid/page/N/index.html, chunking photos into pageSize
func (g *Generator) renderIndexPages(photos []Photo) error { // slices for progressive pagination. The grid is the secondary view; the
// timeline is the site root.
func (g *Generator) renderGridPages(photos []Photo) error {
total := len(photos) total := len(photos)
totalPages := (total + pageSize - 1) / pageSize totalPages := (total + g.pageSize - 1) / g.pageSize
if totalPages == 0 { if totalPages == 0 {
totalPages = 1 totalPages = 1
} }
gridDir := filepath.Join(g.outputDir, "grid")
for i := 0; i < totalPages; i++ { for i := 0; i < totalPages; i++ {
start := i * pageSize start := i * g.pageSize
end := start + pageSize end := start + g.pageSize
if end > total { if end > total {
end = total end = total
} }
slice := photos[start:end] slice := photos[start:end]
dst := filepath.Join(g.outputDir, "index.html") dst := filepath.Join(gridDir, "index.html")
if i > 0 { if i > 0 {
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1)) dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1))
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return err return err
} }
@@ -342,12 +350,12 @@ func (g *Generator) renderIndexPages(photos []Photo) error {
case 0: case 0:
// no prev // no prev
case 1: case 1:
prev = "/" prev = "/grid/"
default: default:
prev = "/page/" + strconv.Itoa(i) + "/" prev = "/grid/page/" + strconv.Itoa(i) + "/"
} }
if i+1 < totalPages { if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/" next = "/grid/page/" + strconv.Itoa(i+2) + "/"
} }
data := map[string]any{ data := map[string]any{
@@ -368,7 +376,7 @@ func (g *Generator) renderIndexPages(photos []Photo) error {
"SerifFamily": g.serifFamily, "SerifFamily": g.serifFamily,
"MlFamily": g.mlFamily, "MlFamily": g.mlFamily,
} }
if err := g.render("index.html", dst, data); err != nil { if err := g.render("grid.html", dst, data); err != nil {
return err return err
} }
} }
@@ -418,24 +426,86 @@ func groupByDay(photos []Photo) []DayGroup {
return groups return groups
} }
// renderTimeline writes public/timeline/index.html, photos grouped by day. // chunkDayGroups packs day groups into pages, filling each page until it holds
func (g *Generator) renderTimeline(photos []Photo) error { // at least target photos, then starting a new one. A single day is never split
dir := filepath.Join(g.outputDir, "timeline") // across a page boundary, so page sizes vary slightly around target. Always
if err := os.MkdirAll(dir, 0755); err != nil { // returns at least one page (empty when there are no photos) so the site root
return err // renders.
func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
var pages [][]DayGroup
var cur []DayGroup
count := 0
for _, grp := range groups {
cur = append(cur, grp)
count += grp.Count
if count >= target {
pages = append(pages, cur)
cur, count = nil, 0
}
} }
return g.render("timeline.html", filepath.Join(dir, "index.html"), map[string]any{ if len(cur) > 0 {
"Groups": groupByDay(photos), pages = append(pages, cur)
"Total": len(photos), }
"BaseURL": g.baseURL, if len(pages) == 0 {
"Year": time.Now().Year(), pages = [][]DayGroup{nil}
"Author": g.author, }
"Handle": g.handle, return pages
"Initial": g.initial(), }
"FontsURL": g.fontsURL,
"SerifFamily": g.serifFamily, // renderTimelinePages writes the day-grouped timeline as the site root:
"MlFamily": g.mlFamily, // index.html and page/N/index.html. Photos are grouped by day, then packed
}) // into pages of roughly pageSize without splitting a day across pages.
func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
totalPages := len(pages)
for i, groups := range pages {
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{
"Groups": groups,
"Total": len(photos),
"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("timeline.html", dst, data); err != nil {
return err
}
}
return nil
} }
func (g *Generator) render(tmpl, dst string, data any) error { func (g *Generator) render(tmpl, dst string, data any) error {
+2 -1
View File
@@ -19,6 +19,7 @@ func main() {
fontsURL := flag.String("fonts-url", "https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap", "stylesheet URL loading the two web fonts") fontsURL := flag.String("fonts-url", "https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap", "stylesheet URL loading the two web fonts")
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text") serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line") mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
flag.Parse() flag.Parse()
for _, d := range []string{*contentDir, *outputDir} { for _, d := range []string{*contentDir, *outputDir} {
@@ -27,7 +28,7 @@ func main() {
} }
} }
g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily) g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize)
if err := g.Build(); err != nil { if err := g.Build(); err != nil {
log.Printf("initial build: %v", err) log.Printf("initial build: %v", err)
+7
View File
@@ -76,6 +76,12 @@ in {
description = "CSS font-family for the alt-script (Malayalam) bio line. Must match a family loaded by fontsURL."; description = "CSS font-family for the alt-script (Malayalam) bio line. Must match a family loaded by fontsURL.";
}; };
pageSize = lib.mkOption {
type = lib.types.ints.positive;
default = 60;
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
};
nginx = { nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery"; enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -141,6 +147,7 @@ in {
"--fonts-url" cfg.fontsURL "--fonts-url" cfg.fontsURL
"--serif-family" cfg.serifFamily "--serif-family" cfg.serifFamily
"--ml-family" cfg.mlFamily "--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize)
]; ];
Restart = "on-failure"; Restart = "on-failure";
+10
View File
@@ -130,6 +130,16 @@ body {
color: var(--fg2); color: var(--fg2);
} }
/* Divider between adjacent header links, e.g. GRID | RSS FEED. The pipe is a
flex child of the second link, so its right margin adds to that link's
gap:5px — 9px + 5px matches the 14px left margin for even spacing. */
.rss-a + .rss-a::before {
content: "|";
margin-left: 14px;
margin-right: 9px;
color: var(--fg4);
}
/* ── Photo grid ────────────────────────────────────────────── */ /* ── Photo grid ────────────────────────────────────────────── */
.grid-wrap { .grid-wrap {
+1 -1
View File
@@ -20,7 +20,7 @@
<p class="handle">{{.Handle}}</p> <p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</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="/timeline/" class="rss-a">Timeline</a> <a href="/" class="rss-a">Timeline</a>
<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">
<circle cx="2.5" cy="11.5" r="1.5"/> <circle cx="2.5" cy="11.5" r="1.5"/>
+59 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timeline · Photo Gallery</title> <title>Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}"> <link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style> <style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
@@ -19,7 +19,16 @@
<h1>{{.Author}}</h1> <h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p> <p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p> <p class="pcount"><strong>{{.Total}}</strong> posts</p>
<a href="/" class="rss-a">← Grid</a> <p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
<a href="/grid/" class="rss-a">Grid</a>
<a href="/feed.xml" class="rss-a">
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="2.5" cy="11.5" r="1.5"/>
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
</svg>
RSS feed
</a>
</div> </div>
</div> </div>
</header> </header>
@@ -38,11 +47,59 @@
</div> </div>
</section> </section>
{{end}} {{end}}
{{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 nav = btn.closest('.pager');
const wrap = nav.parentNode;
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');
// Days never split across pages, so appended sections never duplicate
// a heading already on the page.
doc.querySelectorAll('.tl-day').forEach(s => wrap.insertBefore(s, nav));
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>