From f1abd45f243cf16a9970242dfd1f95389ff89c0b Mon Sep 17 00:00:00 2001 From: Santhosh Raju Date: Mon, 28 Sep 2026 07:10:29 +0200 Subject: [PATCH] feat(generator): add -default-view to choose timeline or grid as site root The chosen view is published at /, the other under /timeline/ or /grid/. Page paths, pager links and the cross-links between the views are derived from the setting. The directory named after the default view is removed on build, since it can only be a leftover from before a switch. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 3 +- README.md | 2 + generator.go | 89 +++++++++++++++++---------- generator_test.go | 132 ++++++++++++++++++++++++++++++++++++++++ main.go | 7 ++- module.nix | 7 +++ templates/day.html | 2 +- templates/grid.html | 2 +- templates/timeline.html | 2 +- 9 files changed, 208 insertions(+), 38 deletions(-) create mode 100644 generator_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 510224b..4ffb2e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,10 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first - Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url` - Manjari for Malayalam text (bio line), via `-ml-family` - 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). + - **Timeline is the site root by default** (`/`, `/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. - Each timeline date heading links to a **single-day page** (`/day//`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug. +- Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `//`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `TimelineURL` — never hardcode `/grid/` or `/`. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch. Root-level `/page/N/` beyond the new page count is *not* pruned. - Single photo page with sidebar and newer/older navigation ### Link previews diff --git a/README.md b/README.md index b10bcab..35f85e5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ nix-shell -p apacheHttpd --run \ | `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts | | `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif | | `mlFamily` | `Manjari` | CSS font-family for the alt-script bio | +| `pageSize` | `60` | Photos per page on the timeline and grid views | +| `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` | | `nginx.enable` | `false` | Configure an nginx vhost | | `nginx.domain` | — | Domain for the vhost | | `nginx.htpasswdFile` | — | htpasswd file for `/upload` | diff --git a/generator.go b/generator.go index 57db661..b101846 100644 --- a/generator.go +++ b/generator.go @@ -27,6 +27,11 @@ const ( mediumMaxSize = 1600 defaultPageSize = 60 + // The two paginated views. The one chosen as -default-view is published + // at the site root, the other under //. + viewTimeline = "timeline" + viewGrid = "grid" + // Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a // 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely // reported to drop the preview entirely once the image passes ~300 KB, so @@ -78,15 +83,19 @@ type Generator struct { serifFamily string mlFamily string pageSize int + defaultView string mu sync.Mutex tmpl *template.Template } -func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int) *Generator { +func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, fontsURL, serifFamily, mlFamily string, pageSize int, defaultView string) *Generator { tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) if pageSize < 1 { pageSize = defaultPageSize } + if defaultView != viewGrid { + defaultView = viewTimeline + } return &Generator{ contentDir: contentDir, outputDir: outputDir, @@ -99,6 +108,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f serifFamily: serifFamily, mlFamily: mlFamily, pageSize: pageSize, + defaultView: defaultView, tmpl: tmpl, } } @@ -110,6 +120,23 @@ func (g *Generator) initial() string { return "" } +// viewBase returns the URL prefix a view is published under: "" for the +// default view, which owns the site root, and "/" for the other one. +func (g *Generator) viewBase(view string) string { + if view == g.defaultView { + return "" + } + return "/" + view +} + +// pageURL returns the URL of the 1-based page n of a view rooted at base. +func pageURL(base string, n int) string { + if n == 1 { + return base + "/" + } + return base + "/page/" + strconv.Itoa(n) + "/" +} + // Build performs a full rebuild of the site. It is safe to call concurrently; // concurrent calls are serialised by a mutex. func (g *Generator) Build() error { @@ -121,7 +148,7 @@ func (g *Generator) Build() error { return err } - for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "static"} { + for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "static"} { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { return err } @@ -133,6 +160,13 @@ func (g *Generator) Build() error { } } + // The default view lives at the site root, so a directory named after it + // can only be left over from a run with the other -default-view. Drop it, + // or the old pages would stay reachable and go stale. + if err := os.RemoveAll(filepath.Join(g.outputDir, g.defaultView)); err != nil { + return err + } + if err := g.renderTimelinePages(photos); err != nil { return err } @@ -417,7 +451,7 @@ func (g *Generator) renderGridPages(photos []Photo) error { if totalPages == 0 { totalPages = 1 } - gridDir := filepath.Join(g.outputDir, "grid") + base := g.viewBase(viewGrid) for i := 0; i < totalPages; i++ { start := i * g.pageSize @@ -427,31 +461,24 @@ func (g *Generator) renderGridPages(photos []Photo) error { } slice := photos[start:end] - dst := filepath.Join(gridDir, "index.html") - if i > 0 { - dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1)) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - dst = filepath.Join(dir, "index.html") + dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, 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 = "/grid/" - default: - prev = "/grid/page/" + strconv.Itoa(i) + "/" + if i > 0 { + prev = pageURL(base, i) } if i+1 < totalPages { - next = "/grid/page/" + strconv.Itoa(i+2) + "/" + next = pageURL(base, i+2) } data := map[string]any{ "Photos": slice, + "TimelineURL": pageURL(g.viewBase(viewTimeline), 1), "Total": total, "Page": i + 1, "TotalPages": totalPages, @@ -551,32 +578,27 @@ func (g *Generator) renderTimelinePages(photos []Photo) error { pages := chunkDayGroups(groupByDay(photos), g.pageSize) totalPages := len(pages) + base := g.viewBase(viewTimeline) + 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") + dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, 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 > 0 { + prev = pageURL(base, i) } if i+1 < totalPages { - next = "/page/" + strconv.Itoa(i+2) + "/" + next = pageURL(base, i+2) } data := map[string]any{ "Groups": groups, + "GridURL": pageURL(g.viewBase(viewGrid), 1), "Total": len(photos), "Page": i + 1, "TotalPages": totalPages, @@ -610,6 +632,7 @@ func (g *Generator) renderDayPages(photos []Photo) error { } err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{ "Date": grp.Label, + "TimelineURL": pageURL(g.viewBase(viewTimeline), 1), "Count": grp.Count, "Photos": grp.Photos, "Total": len(photos), diff --git a/generator_test.go b/generator_test.go new file mode 100644 index 0000000..00ea7ff --- /dev/null +++ b/generator_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "bytes" + "image" + "image/jpeg" + "os" + "path/filepath" + "strings" + "testing" +) + +// buildSite builds a site of three photos spread over two days, with a page +// size of two so both views need a second page, and returns the output dir. +func buildSite(t *testing.T, output, defaultView string) { + t.Helper() + content := t.TempDir() + + var img bytes.Buffer + if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil { + t.Fatal(err) + } + for slug, date := range map[string]string{ + "one": "2026-06-14", + "two": "2026-06-14", + "three": "2026-06-10", + } { + writeFile(t, filepath.Join(content, slug+".jpg"), img.Bytes()) + writeFile(t, filepath.Join(content, slug+".toml"), + []byte("caption = \""+slug+"\"\ndate = \""+date+"\"\n")) + } + + g := NewGenerator(content, output, "https://photos.example.com", + "Ada", "", "", "", "", "Crimson Pro", "Manjari", 2, defaultView) + if err := g.Build(); err != nil { + t.Fatal(err) + } +} + +func writeFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } +} + +func readPage(t *testing.T, output, rel string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(output, filepath.FromSlash(rel), "index.html")) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// Timeline pages carry day sections, grid pages do not. +func isTimeline(page string) bool { + return strings.Contains(page, `class="tl-day"`) +} + +func TestDefaultViewOwnsSiteRoot(t *testing.T) { + tests := []struct { + defaultView string + gridAt string // URL of the grid's first page + timelineAt string // URL of the timeline's first page + }{ + {viewTimeline, "/grid/", "/"}, + {viewGrid, "/", "/timeline/"}, + {"nonsense", "/grid/", "/"}, // unknown values fall back to the timeline + } + for _, tt := range tests { + t.Run(tt.defaultView, func(t *testing.T) { + output := t.TempDir() + buildSite(t, output, tt.defaultView) + + timeline := readPage(t, output, tt.timelineAt) + grid := readPage(t, output, tt.gridAt) + if !isTimeline(timeline) { + t.Errorf("%s is not the timeline", tt.timelineAt) + } + if isTimeline(grid) { + t.Errorf("%s is not the grid", tt.gridAt) + } + + // The views link to each other, and to their own second page. + for page, want := range map[string]string{ + timeline: `href="` + tt.gridAt + `" class="rss-a">Grid`, + grid: `href="` + tt.timelineAt + `" class="rss-a">Timeline`, + } { + if !strings.Contains(page, want) { + t.Errorf("missing link %q", want) + } + } + for first, base := range map[string]string{ + timeline: strings.TrimSuffix(tt.timelineAt, "/"), + grid: strings.TrimSuffix(tt.gridAt, "/"), + } { + next := base + "/page/2/" + if !strings.Contains(first, `href="`+next+`" data-load-more`) { + t.Errorf("first page does not link to %s", next) + } + second := readPage(t, output, next) + if !strings.Contains(second, `class="pager-newer" href="`+base+`/"`) { + t.Errorf("%s does not link back to %s/", next, base) + } + } + + // Day pages lead back to the timeline, wherever it lives. + day := readPage(t, output, "/day/2026-06-14/") + if !strings.Contains(day, `href="`+tt.timelineAt+`" class="rss-a">← Timeline`) { + t.Errorf("day page does not link to the timeline at %s", tt.timelineAt) + } + }) + } +} + +func TestSwitchingDefaultViewDropsOldViewDir(t *testing.T) { + output := t.TempDir() + + buildSite(t, output, viewTimeline) + if _, err := os.Stat(filepath.Join(output, "grid", "index.html")); err != nil { + t.Fatalf("grid view missing before the switch: %v", err) + } + + buildSite(t, output, viewGrid) + if _, err := os.Stat(filepath.Join(output, "grid")); !os.IsNotExist(err) { + t.Errorf("stale /grid/ survived the switch to -default-view grid") + } + if isTimeline(readPage(t, output, "/")) { + t.Errorf("site root is still the timeline") + } +} diff --git a/main.go b/main.go index e9d706b..afc0ff9 100644 --- a/main.go +++ b/main.go @@ -20,15 +20,20 @@ func main() { 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") 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)") + defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under //") flag.Parse() + if *defaultView != viewTimeline && *defaultView != viewGrid { + log.Fatalf("-default-view must be %q or %q, got %q", viewTimeline, viewGrid, *defaultView) + } + for _, d := range []string{*contentDir, *outputDir} { if err := os.MkdirAll(d, 0755); err != nil { log.Fatalf("mkdir %s: %v", d, err) } } - g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize) + g := NewGenerator(*contentDir, *outputDir, *baseURL, *author, *handle, *bio, *bioAlt, *fontsURL, *serifFamily, *mlFamily, *pageSize, *defaultView) if err := g.Build(); err != nil { log.Printf("initial build: %v", err) diff --git a/module.nix b/module.nix index 2080abe..260b2f9 100644 --- a/module.nix +++ b/module.nix @@ -82,6 +82,12 @@ in { description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this."; }; + defaultView = lib.mkOption { + type = lib.types.enum [ "timeline" "grid" ]; + default = "timeline"; + description = "View published at the site root. The other one is published under /timeline/ or /grid/."; + }; + nginx = { enable = lib.mkEnableOption "nginx virtual host for photogallery"; @@ -148,6 +154,7 @@ in { "--serif-family" cfg.serifFamily "--ml-family" cfg.mlFamily "--page-size" (toString cfg.pageSize) + "--default-view" cfg.defaultView ]; Restart = "on-failure"; diff --git a/templates/day.html b/templates/day.html index 1089d06..1052fe2 100644 --- a/templates/day.html +++ b/templates/day.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

- ← Timeline + ← Timeline diff --git a/templates/grid.html b/templates/grid.html index 5cba5e8..2955f01 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

- Timeline + Timeline

{{.Handle}}

{{.Total}} posts

{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}

-
Grid + Grid