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
This commit is contained in:
puttaalu committed 2026-10-18 22:06:46 +02:00
1 parent a7f2afc3ad
commit 8fa1aa4cd4
13 files changed
+209 -56

No files matched your search

+70 -46
View File
@@ -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 /<name>/.
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 "/<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) + "/"
}
// 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,