From 8fa1aa4cd4855d1374c73dbd5cc4e957e8fbf970 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Sun, 18 Oct 2026 22:06:46 +0200 Subject: [PATCH] Add -default-view to choose timeline or grid as the site root The view named by -default-view (timeline, the default, or grid) is published at the site root; the other is served under /timeline/ or /grid/. viewBase and pageURL derive every page path, pager link and the Timeline<->Grid cross-link from the single setting, and Build removes both timeline/ and grid/ on each run so flipping the default never leaves a stale root behind. Reimplemented on trunk (which has albums and the /manage feature) rather than cherry-picked, since the original was written against an earlier base. Credit for the edit/delete and default-view ideas goes to Santhosh Raju, now noted in a README Contributors section. - main.go: -default-view flag, validated (fatal on anything but timeline/grid) - module.nix: services.photogallery.defaultView enum option - templates: timeline/grid/day/album/albums cross-links use TimelineURL/GridURL - tests: timeline-root, grid-root, and switch-cleanup --- CLAUDE.md | 6 +-- README.md | 7 +++ albums.go | 2 + default_view_test.go | 108 +++++++++++++++++++++++++++++++++++++ generator.go | 116 ++++++++++++++++++++++++---------------- main.go | 7 ++- manage_test.go | 2 +- module.nix | 7 +++ templates/album.html | 2 +- templates/albums.html | 2 +- templates/day.html | 2 +- templates/grid.html | 2 +- templates/timeline.html | 2 +- 13 files changed, 209 insertions(+), 56 deletions(-) create mode 100644 default_view_test.go diff --git a/CLAUDE.md b/CLAUDE.md index dad8823..f5dffc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,9 +113,9 @@ contain spaces (`"berlin trip"`), and its URL slug is derived with `sanitiseSlug - Gruvbox dark hard colour scheme - 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). - - **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header. +- 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`). Which view sits at the site root is chosen by `-default-view` (`timeline` or `grid`, default `timeline`; NixOS `services.photogallery.defaultView`): the chosen one owns `/` (`/`, `/page/2/`, …) and the other is published under `//`. `viewBase`/`pageURL` (`generator.go`) derive every page path, pager link and the Timeline↔Grid cross-links (`TimelineURL`/`GridURL` in the template data) from that one setting; `Build` removes both `timeline/` and `grid/` each run so a flipped default leaves no stale root behind. + - **Timeline** (the default root): 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** is the flat square grid. At `/grid/` by default, or at the root when `-default-view grid`. - 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. - Single photo page with sidebar and newer/older navigation diff --git a/README.md b/README.md index b10bcab..f0643ec 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ A minimal static photo gallery with an Atom feed and an upload API. +## Contributors + +- **[Santhosh Raju](https://git.planet-express.in/fox)** — originated the ideas for the edit/delete ("manage") + capability and the configurable default view (`-default-view`, timeline or + grid as the site root). Both landed here as independent implementations + building on that work. + ## How it works 1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`). diff --git a/albums.go b/albums.go index 23ca4ed..dd2c3f9 100644 --- a/albums.go +++ b/albums.go @@ -108,6 +108,8 @@ func (g *Generator) baseData() map[string]any { "FontsURL": g.fontsURL, "SerifFamily": g.serifFamily, "MlFamily": g.mlFamily, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), } } diff --git a/default_view_test.go b/default_view_test.go new file mode 100644 index 0000000..924c0d5 --- /dev/null +++ b/default_view_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// buildWithView builds a two-photo site with the given default view and returns +// the output dir. +func buildWithView(t *testing.T, view string) string { + t.Helper() + content := t.TempDir() + output := t.TempDir() + writePNG(t, filepath.Join(content, "a.png")) + writePNG(t, filepath.Join(content, "b.png")) + + g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, view) + if err := g.Build(); err != nil { + t.Fatalf("build (%s): %v", view, err) + } + return output +} + +func exists(p string) bool { _, err := os.Stat(p); return err == nil } + +func TestDefaultViewTimeline(t *testing.T) { + out := buildWithView(t, viewTimeline) + + // Timeline owns the root; grid is the secondary under /grid/. + if !exists(filepath.Join(out, "index.html")) { + t.Error("root index.html missing") + } + if !exists(filepath.Join(out, "grid", "index.html")) { + t.Error("/grid/ should exist as the secondary view") + } + // No /timeline/ dir — timeline is at the root, not under its own name. + if exists(filepath.Join(out, "timeline")) { + t.Error("/timeline/ should not exist when timeline is the default") + } + + // The root (timeline) links to grid at /grid/. + root := readFile(t, filepath.Join(out, "index.html")) + if !strings.Contains(root, `href="/grid/"`) { + t.Error("timeline root should link to /grid/") + } +} + +func TestDefaultViewGrid(t *testing.T) { + out := buildWithView(t, viewGrid) + + // Grid owns the root; timeline is the secondary under /timeline/. + if !exists(filepath.Join(out, "index.html")) { + t.Error("root index.html missing") + } + if !exists(filepath.Join(out, "timeline", "index.html")) { + t.Error("/timeline/ should exist as the secondary view") + } + // No /grid/ dir — grid is at the root now. + if exists(filepath.Join(out, "grid")) { + t.Error("/grid/ should not exist when grid is the default") + } + + // The root (grid) links back to the timeline at /timeline/. + root := readFile(t, filepath.Join(out, "index.html")) + if !strings.Contains(root, `href="/timeline/"`) { + t.Error("grid root should link to /timeline/") + } +} + +// TestDefaultViewSwitchCleansUp confirms that flipping the default view does not +// leave the previous root's view directory behind. +func TestDefaultViewSwitchCleansUp(t *testing.T) { + content := t.TempDir() + output := t.TempDir() + writePNG(t, filepath.Join(content, "a.png")) + + // First build with grid as root → creates /timeline/. + g1 := NewGenerator(content, output, "http://x", "A", "@a", "b", "", "f", "s", "m", 60, viewGrid) + if err := g1.Build(); err != nil { + t.Fatal(err) + } + if !exists(filepath.Join(output, "timeline")) { + t.Fatal("expected /timeline/ after grid-default build") + } + + // Rebuild with timeline as root → /timeline/ must be gone, /grid/ present. + g2 := NewGenerator(content, output, "http://x", "A", "@a", "b", "", "f", "s", "m", 60, viewTimeline) + if err := g2.Build(); err != nil { + t.Fatal(err) + } + if exists(filepath.Join(output, "timeline")) { + t.Error("/timeline/ left over after switching default to timeline") + } + if !exists(filepath.Join(output, "grid", "index.html")) { + t.Error("/grid/ should exist after switching default to timeline") + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/generator.go b/generator.go index 04c97d9..e7456ce 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 @@ -80,15 +85,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, @@ -101,10 +110,33 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f serifFamily: serifFamily, mlFamily: mlFamily, pageSize: pageSize, + defaultView: defaultView, tmpl: tmpl, } } +// 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) + "/" +} + +// timelineURL and gridURL are the roots of each view, for cross-links in the +// page headers. +func (g *Generator) timelineURL() string { return pageURL(g.viewBase(viewTimeline), 1) } +func (g *Generator) gridURL() string { return pageURL(g.viewBase(viewGrid), 1) } + func (g *Generator) initial() string { for _, r := range g.author { return strings.ToUpper(string(r)) @@ -124,19 +156,19 @@ func (g *Generator) Build() error { } // Drop fully-derived HTML trees before regenerating them, so a deleted - // post, a retag or a changed date leaves no orphaned page behind. These are - // cheap to rewrite (no up-to-date skipping applies to HTML), unlike the - // image renditions, which are pruned by slug just below. - for _, d := range []string{"photo", "day", "album", "page"} { + // post, a retag, a changed date or a flipped -default-view leaves no + // orphaned page behind. These are cheap to rewrite (no up-to-date skipping + // applies to HTML), unlike the image renditions, which are pruned by slug + // just below. Both "timeline" and "grid" are removed: whichever is the + // default owns the site root and has no dir of its own, so a dir by that + // name can only be a leftover from the opposite -default-view. + for _, d := range []string{"photo", "day", "album", "page", "timeline", "grid"} { if err := os.RemoveAll(filepath.Join(g.outputDir, d)); err != nil { return err } } - if err := os.RemoveAll(filepath.Join(g.outputDir, "grid", "page")); err != nil { - return err - } - for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "album", "albums", "static"} { + for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "album", "albums", "static"} { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { return err } @@ -467,17 +499,17 @@ func generateOG(src, dst string, maxDim int) error { return os.WriteFile(dst, buf.Bytes(), 0644) } -// renderGridPages writes the paginated square-grid view under public/grid/: -// grid/index.html and grid/page/N/index.html, chunking photos into pageSize -// slices for progressive pagination. The grid is the secondary view; the -// timeline is the site root. +// renderGridPages writes the paginated square-grid view. When grid is the +// -default-view it is the site root (index.html + page/N/); otherwise it lives +// under /grid/. Photos are chunked into pageSize slices for progressive +// pagination. func (g *Generator) renderGridPages(photos []Photo) error { total := len(photos) totalPages := (total + g.pageSize - 1) / g.pageSize 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 @@ -487,27 +519,19 @@ 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{ @@ -517,6 +541,8 @@ func (g *Generator) renderGridPages(photos []Photo) error { "TotalPages": totalPages, "PrevURL": prev, "NextURL": next, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, @@ -604,35 +630,29 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup { return pages } -// renderTimelinePages writes the day-grouped timeline as the site root: -// index.html and page/N/index.html. Photos are grouped by day, then packed +// renderTimelinePages writes the day-grouped timeline. When timeline is the +// -default-view (the default) it is the site root (index.html + page/N/); +// otherwise it lives under /timeline/. 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) + 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{ @@ -642,6 +662,8 @@ func (g *Generator) renderTimelinePages(photos []Photo) error { "TotalPages": totalPages, "PrevURL": prev, "NextURL": next, + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, @@ -673,6 +695,8 @@ func (g *Generator) renderDayPages(photos []Photo) error { "Count": grp.Count, "Photos": grp.Photos, "Total": len(photos), + "TimelineURL": g.timelineURL(), + "GridURL": g.gridURL(), "BaseURL": g.baseURL, "Year": time.Now().Year(), "Author": g.author, diff --git a/main.go b/main.go index 5080078..819d9b1 100644 --- a/main.go +++ b/main.go @@ -21,15 +21,20 @@ func main() { 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)") htpasswd := flag.String("htpasswd", "", "path to an htpasswd file (bcrypt, `htpasswd -B`) guarding /upload; empty disables auth") + defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other 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/manage_test.go b/manage_test.go index bfa1b16..ddfe274 100644 --- a/manage_test.go +++ b/manage_test.go @@ -29,7 +29,7 @@ func newTestServer(t *testing.T) (*Server, string, string) { } writePNG(t, filepath.Join(content, "bar.png")) // no sidecar - g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60) + g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, viewTimeline) if err := g.Build(); err != nil { t.Fatalf("initial build: %v", err) } diff --git a/module.nix b/module.nix index dd5f855..f58c784 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 is published under /timeline/ or /grid/."; + }; + htpasswdFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; @@ -156,6 +162,7 @@ in { "--serif-family" cfg.serifFamily "--ml-family" cfg.mlFamily "--page-size" (toString cfg.pageSize) + "--default-view" cfg.defaultView ] ++ lib.optionals (cfg.htpasswdFile != null) [ # %d is systemd's credentials directory; LoadCredential below drops # the htpasswd file there, readable only by this service. diff --git a/templates/album.html b/templates/album.html index 7f4ce95..26a3deb 100644 --- a/templates/album.html +++ b/templates/album.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

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

- Timeline + Timeline ← Albums diff --git a/templates/albums.html b/templates/albums.html index c434856..68c926d 100644 --- a/templates/albums.html +++ b/templates/albums.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

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

- ← Timeline + ← Timeline 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 027b876..fc7eb57 100644 --- a/templates/grid.html +++ b/templates/grid.html @@ -23,7 +23,7 @@

{{.Handle}}

{{.Total}} posts

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

- Timeline + Timeline Albums

{{.Handle}}

{{.Total}} posts

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

-
Grid + Grid Albums