diff --git a/.gitignore b/.gitignore index 4a584a2..39906a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /content/ /public/ +/.demo/ # Anchored: unanchored these would also match the Kotlin package directory # android/app/src/main/java/ws/inflo/photogallery/ and any nested "result". /photogallery diff --git a/CLAUDE.md b/CLAUDE.md index 43f5ce4..65dde11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,9 +45,10 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first - 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` -- 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 -- 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 @@ -130,8 +131,9 @@ nix build # succeeds | `server.go` | Upload API + optional static file serving | | `watcher.go` | fsnotify watcher with 500ms debounce | | `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/timeline.html` | Timeline page, photos grouped by day | | `static/photo-nav.js` | Arrow-key and swipe navigation | | `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) | | `flake.nix` | `buildGoModule` package + devShell + nixosModules | diff --git a/generator.go b/generator.go index c43c230..7fe310f 100644 --- a/generator.go +++ b/generator.go @@ -22,9 +22,9 @@ import ( ) const ( - thumbSize = 600 - mediumMaxSize = 1600 - pageSize = 60 + thumbSize = 600 + mediumMaxSize = 1600 + defaultPageSize = 60 ) // PhotoMeta is the structure of a .toml sidecar file. @@ -62,12 +62,16 @@ type Generator struct { fontsURL string serifFamily string mlFamily string + pageSize int mu sync.Mutex 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")) + if pageSize < 1 { + pageSize = defaultPageSize + } return &Generator{ contentDir: contentDir, outputDir: outputDir, @@ -79,6 +83,7 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f fontsURL: fontsURL, serifFamily: serifFamily, mlFamily: mlFamily, + pageSize: pageSize, tmpl: tmpl, } } @@ -101,7 +106,7 @@ func (g *Generator) Build() error { 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 { 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 } for i := range photos { @@ -128,9 +136,6 @@ func (g *Generator) Build() error { return err } } - if err := g.renderTimeline(photos); err != nil { - return err - } if err := g.renderFeed(photos); err != nil { return err } @@ -310,26 +315,29 @@ func generateMedium(src, dst string, maxDim int) error { return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88}) } -// renderIndexPages writes public/index.html and public/page/N/index.html -// files, chunking photos into pageSize slices for progressive pagination. -func (g *Generator) renderIndexPages(photos []Photo) error { +// 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. +func (g *Generator) renderGridPages(photos []Photo) error { total := len(photos) - totalPages := (total + pageSize - 1) / pageSize + totalPages := (total + g.pageSize - 1) / g.pageSize if totalPages == 0 { totalPages = 1 } + gridDir := filepath.Join(g.outputDir, "grid") for i := 0; i < totalPages; i++ { - start := i * pageSize - end := start + pageSize + start := i * g.pageSize + end := start + g.pageSize if end > total { end = total } slice := photos[start:end] - dst := filepath.Join(g.outputDir, "index.html") + dst := filepath.Join(gridDir, "index.html") 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 { return err } @@ -342,12 +350,12 @@ func (g *Generator) renderIndexPages(photos []Photo) error { case 0: // no prev case 1: - prev = "/" + prev = "/grid/" default: - prev = "/page/" + strconv.Itoa(i) + "/" + prev = "/grid/page/" + strconv.Itoa(i) + "/" } if i+1 < totalPages { - next = "/page/" + strconv.Itoa(i+2) + "/" + next = "/grid/page/" + strconv.Itoa(i+2) + "/" } data := map[string]any{ @@ -368,7 +376,7 @@ func (g *Generator) renderIndexPages(photos []Photo) error { "SerifFamily": g.serifFamily, "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 } } @@ -418,24 +426,86 @@ func groupByDay(photos []Photo) []DayGroup { return groups } -// renderTimeline writes public/timeline/index.html, photos grouped by day. -func (g *Generator) renderTimeline(photos []Photo) error { - dir := filepath.Join(g.outputDir, "timeline") - if err := os.MkdirAll(dir, 0755); err != nil { - return err +// chunkDayGroups packs day groups into pages, filling each page until it holds +// at least target photos, then starting a new one. A single day is never split +// across a page boundary, so page sizes vary slightly around target. Always +// returns at least one page (empty when there are no photos) so the site root +// 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{ - "Groups": groupByDay(photos), - "Total": len(photos), - "BaseURL": g.baseURL, - "Year": time.Now().Year(), - "Author": g.author, - "Handle": g.handle, - "Initial": g.initial(), - "FontsURL": g.fontsURL, - "SerifFamily": g.serifFamily, - "MlFamily": g.mlFamily, - }) + if len(cur) > 0 { + pages = append(pages, cur) + } + if len(pages) == 0 { + pages = [][]DayGroup{nil} + } + 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 +// 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 { diff --git a/main.go b/main.go index c2480d2..e9d706b 100644 --- a/main.go +++ b/main.go @@ -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") 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)") flag.Parse() 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 { log.Printf("initial build: %v", err) diff --git a/module.nix b/module.nix index b43989f..2080abe 100644 --- a/module.nix +++ b/module.nix @@ -76,6 +76,12 @@ in { 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 = { enable = lib.mkEnableOption "nginx virtual host for photogallery"; @@ -141,6 +147,7 @@ in { "--fonts-url" cfg.fontsURL "--serif-family" cfg.serifFamily "--ml-family" cfg.mlFamily + "--page-size" (toString cfg.pageSize) ]; Restart = "on-failure"; diff --git a/static/style.css b/static/style.css index 866c0c9..3ca3c7c 100644 --- a/static/style.css +++ b/static/style.css @@ -130,6 +130,16 @@ body { 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 ────────────────────────────────────────────── */ .grid-wrap { diff --git a/templates/index.html b/templates/grid.html similarity index 98% rename from templates/index.html rename to templates/grid.html index c24b811..05a256d 100644 --- a/templates/index.html +++ b/templates/grid.html @@ -20,7 +20,7 @@
{{.Handle}}
{{.Total}} posts
{{.Bio}}{{if .BioAlt}}
{{.BioAlt}}{{end}}