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 <noreply@anthropic.com>
This commit is contained in:
fox
2026-09-30 05:53:12 +00:00
committed by fox
co-authored by Claude Fable 5.1
parent 4b85dba8e1
commit 52ff47217a
10 changed files with 202 additions and 39 deletions
+2 -1
View File
@@ -55,9 +55,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` - 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`
- 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`): - 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. - **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/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug. - Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) 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 `/<name>/`: 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 - Single photo page with sidebar and newer/older navigation
### Link previews ### Link previews
+2
View File
@@ -101,6 +101,8 @@ nix-shell -p apacheHttpd --run \
| `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts | | `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
| `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif | | `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif |
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio | | `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.enable` | `false` | Configure an nginx vhost |
| `nginx.domain` | — | Domain for the vhost | | `nginx.domain` | — | Domain for the vhost |
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` | | `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` |
+56 -33
View File
@@ -27,6 +27,11 @@ const (
mediumMaxSize = 1600 mediumMaxSize = 1600
defaultPageSize = 60 defaultPageSize = 60
// The two paginated views. The one chosen as -default-view is published
// at the site root, the other under /<name>/.
viewTimeline = "timeline"
viewGrid = "grid"
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a // Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely // 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
// reported to drop the preview entirely once the image passes ~300 KB, so // reported to drop the preview entirely once the image passes ~300 KB, so
@@ -78,15 +83,19 @@ type Generator struct {
serifFamily string serifFamily string
mlFamily string mlFamily string
pageSize int pageSize int
defaultView string
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, 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")) tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
if pageSize < 1 { if pageSize < 1 {
pageSize = defaultPageSize pageSize = defaultPageSize
} }
if defaultView != viewGrid {
defaultView = viewTimeline
}
return &Generator{ return &Generator{
contentDir: contentDir, contentDir: contentDir,
outputDir: outputDir, outputDir: outputDir,
@@ -99,6 +108,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
serifFamily: serifFamily, serifFamily: serifFamily,
mlFamily: mlFamily, mlFamily: mlFamily,
pageSize: pageSize, pageSize: pageSize,
defaultView: defaultView,
tmpl: tmpl, tmpl: tmpl,
} }
} }
@@ -110,6 +120,23 @@ func (g *Generator) initial() string {
return "" return ""
} }
// viewBase returns the URL prefix a view is published under: "" for the
// default view, which owns the site root, and "/<view>" 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; // Build performs a full rebuild of the site. It is safe to call concurrently;
// concurrent calls are serialised by a mutex. // concurrent calls are serialised by a mutex.
func (g *Generator) Build() error { func (g *Generator) Build() error {
@@ -121,7 +148,7 @@ func (g *Generator) Build() error {
return err 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 { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err 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 { if err := g.renderTimelinePages(photos); err != nil {
return err return err
} }
@@ -417,7 +451,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
if totalPages == 0 { if totalPages == 0 {
totalPages = 1 totalPages = 1
} }
gridDir := filepath.Join(g.outputDir, "grid") base := g.viewBase(viewGrid)
for i := 0; i < totalPages; i++ { for i := 0; i < totalPages; i++ {
start := i * g.pageSize start := i * g.pageSize
@@ -427,31 +461,24 @@ func (g *Generator) renderGridPages(photos []Photo) error {
} }
slice := photos[start:end] slice := photos[start:end]
dst := filepath.Join(gridDir, "index.html") dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if i > 0 { if err := os.MkdirAll(dir, 0755); err != nil {
dir := filepath.Join(gridDir, "page", strconv.Itoa(i+1)) return err
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
} }
dst := filepath.Join(dir, "index.html")
prev := "" prev := ""
next := "" next := ""
switch i { if i > 0 {
case 0: prev = pageURL(base, i)
// no prev
case 1:
prev = "/grid/"
default:
prev = "/grid/page/" + strconv.Itoa(i) + "/"
} }
if i+1 < totalPages { if i+1 < totalPages {
next = "/grid/page/" + strconv.Itoa(i+2) + "/" next = pageURL(base, i+2)
} }
data := map[string]any{ data := map[string]any{
"Photos": slice, "Photos": slice,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Total": total, "Total": total,
"Page": i + 1, "Page": i + 1,
"TotalPages": totalPages, "TotalPages": totalPages,
@@ -551,32 +578,27 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
pages := chunkDayGroups(groupByDay(photos), g.pageSize) pages := chunkDayGroups(groupByDay(photos), g.pageSize)
totalPages := len(pages) totalPages := len(pages)
base := g.viewBase(viewTimeline)
for i, groups := range pages { for i, groups := range pages {
dst := filepath.Join(g.outputDir, "index.html") dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, i+1)))
if i > 0 { if err := os.MkdirAll(dir, 0755); err != nil {
dir := filepath.Join(g.outputDir, "page", strconv.Itoa(i+1)) return err
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
dst = filepath.Join(dir, "index.html")
} }
dst := filepath.Join(dir, "index.html")
prev := "" prev := ""
next := "" next := ""
switch i { if i > 0 {
case 0: prev = pageURL(base, i)
// no prev
case 1:
prev = "/"
default:
prev = "/page/" + strconv.Itoa(i) + "/"
} }
if i+1 < totalPages { if i+1 < totalPages {
next = "/page/" + strconv.Itoa(i+2) + "/" next = pageURL(base, i+2)
} }
data := map[string]any{ data := map[string]any{
"Groups": groups, "Groups": groups,
"GridURL": pageURL(g.viewBase(viewGrid), 1),
"Total": len(photos), "Total": len(photos),
"Page": i + 1, "Page": i + 1,
"TotalPages": totalPages, "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{ err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
"Date": grp.Label, "Date": grp.Label,
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
"Count": grp.Count, "Count": grp.Count,
"Photos": grp.Photos, "Photos": grp.Photos,
"Total": len(photos), "Total": len(photos),
+125
View File
@@ -0,0 +1,125 @@
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 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")
}
}
+6 -1
View File
@@ -20,15 +20,20 @@ func main() {
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)") 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 /<name>/")
flag.Parse() 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} { for _, d := range []string{*contentDir, *outputDir} {
if err := os.MkdirAll(d, 0755); err != nil { if err := os.MkdirAll(d, 0755); err != nil {
log.Fatalf("mkdir %s: %v", d, err) 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 { if err := g.Build(); err != nil {
log.Printf("initial build: %v", err) log.Printf("initial build: %v", err)
+1 -1
View File
@@ -46,7 +46,7 @@ func newTestServer(t *testing.T) *Server {
[]byte("caption = \"Tempelhof at sunrise\"\ndate = \"2026-06-14\"\n")) []byte("caption = \"Tempelhof at sunrise\"\ndate = \"2026-06-14\"\n"))
g := NewGenerator(content, output, "https://photos.example.com", g := NewGenerator(content, output, "https://photos.example.com",
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60) "Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
return NewServer("0", content, output, false, g) return NewServer("0", content, output, false, g)
} }
+7
View File
@@ -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."; 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 = { nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery"; enable = lib.mkEnableOption "nginx virtual host for photogallery";
@@ -148,6 +154,7 @@ in {
"--serif-family" cfg.serifFamily "--serif-family" cfg.serifFamily
"--ml-family" cfg.mlFamily "--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize) "--page-size" (toString cfg.pageSize)
"--default-view" cfg.defaultView
]; ];
Restart = "on-failure"; Restart = "on-failure";
+1 -1
View File
@@ -23,7 +23,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="/" class="rss-a">← Timeline</a> <a href="{{.TimelineURL}}" class="rss-a">← Timeline</a>
</div> </div>
</div> </div>
</header> </header>
+1 -1
View File
@@ -23,7 +23,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="/" class="rss-a">Timeline</a> <a href="{{.TimelineURL}}" 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"/>
+1 -1
View File
@@ -23,7 +23,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="/grid/" class="rss-a">Grid</a> <a href="{{.GridURL}}" class="rss-a">Grid</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"/>