Add a timeline page grouping photos by day

New /timeline/ page renders photos grouped into consecutive same-day
runs (ISO date heading plus per-day count), reusing the square grid.
groupByDay collapses the newest-first photo list; the page is not
paginated. Linked from the profile header.
This commit is contained in:
2026-08-19 23:46:28 +02:00
parent c169c030d6
commit 9b6fcb482b
5 changed files with 132 additions and 0 deletions
+2
View File
@@ -47,6 +47,7 @@ Missing sidecar → filename as caption, today as date. Photos sort newest first
- Manjari for Malayalam text (bio line), via `-ml-family` - Manjari for Malayalam text (bio line), via `-ml-family`
- 3-column square grid → 2-column on mobile, 60 photos per page (`/`, `/page/2/`, …) - 3-column square grid → 2-column on mobile, 60 photos per page (`/`, `/page/2/`, …)
- Single photo page with sidebar and newer/older navigation - 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 ### Photo page navigation
@@ -130,6 +131,7 @@ nix build # succeeds
| `watcher.go` | fsnotify watcher with 500ms debounce | | `watcher.go` | fsnotify watcher with 500ms debounce |
| `assets.go` | `//go:embed` for templates and static | | `assets.go` | `//go:embed` for templates and static |
| `templates/photo.html` | Single photo page, neighbour links and prefetch | | `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 | | `static/photo-nav.js` | Arrow-key and swipe navigation |
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) | | `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
| `flake.nix` | `buildGoModule` package + devShell + nixosModules | | `flake.nix` | `buildGoModule` package + devShell + nixosModules |
+53
View File
@@ -43,6 +43,14 @@ type Photo struct {
Date time.Time Date time.Time
} }
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
type DayGroup struct {
day string // "2006-01-02", grouping key (unexported: not for templates)
Label string // "14 June 2026"
Count int
Photos []Photo
}
type Generator struct { type Generator struct {
contentDir string contentDir string
outputDir string outputDir string
@@ -120,6 +128,9 @@ func (g *Generator) Build() error {
return err return err
} }
} }
if err := g.renderTimeline(photos); err != nil {
return err
}
if err := g.renderFeed(photos); err != nil { if err := g.renderFeed(photos); err != nil {
return err return err
} }
@@ -385,6 +396,48 @@ func (g *Generator) renderPhotoPage(p Photo, newer, older *Photo) error {
}) })
} }
// groupByDay collapses photos (already sorted newest first) into per-day runs,
// preserving order. Two photos land in the same group iff their dates share a
// calendar day.
func groupByDay(photos []Photo) []DayGroup {
var groups []DayGroup
for _, p := range photos {
day := p.Date.Format("2006-01-02")
if n := len(groups); n > 0 && groups[n-1].day == day {
groups[n-1].Photos = append(groups[n-1].Photos, p)
groups[n-1].Count++
continue
}
groups = append(groups, DayGroup{
day: day,
Label: day, // ISO 2006-01-02
Count: 1,
Photos: []Photo{p},
})
}
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
}
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,
})
}
func (g *Generator) render(tmpl, dst string, data any) error { func (g *Generator) render(tmpl, dst string, data any) error {
f, err := os.Create(dst) f, err := os.Create(dst)
if err != nil { if err != nil {
+28
View File
@@ -187,6 +187,34 @@ body {
.pager-newer { margin-right: auto; } .pager-newer { margin-right: auto; }
.pager-older { margin-left: auto; } .pager-older { margin-left: auto; }
/* ── Timeline ──────────────────────────────────────────────── */
.tl-day {
margin-bottom: 34px;
}
.tl-date {
display: flex;
align-items: baseline;
gap: 10px;
font-family: monospace;
font-size: 15px;
font-weight: 600;
color: var(--fg2);
letter-spacing: 0.04em;
padding: 0 3px 10px;
margin-bottom: 3px;
border-bottom: 1px solid var(--line);
}
.tl-count {
font-family: monospace;
font-size: 11px;
font-weight: 400;
color: var(--fg4);
letter-spacing: 0.04em;
}
/* ── Single photo page ─────────────────────────────────────── */ /* ── Single photo page ─────────────────────────────────────── */
.photo-page { .photo-page {
+1
View File
@@ -20,6 +20,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="/timeline/" 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"/>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timeline · Photo Gallery</title>
<link rel="stylesheet" href="{{.FontsURL}}">
<link rel="stylesheet" href="/static/style.css">
<style>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
</head>
<body>
<header class="profile-hdr">
<div class="profile-inner">
<div class="avatar-ring">
<span class="avatar-initials">{{.Initial}}</span>
</div>
<div class="pinfo">
<h1>{{.Author}}</h1>
<p class="handle">{{.Handle}}</p>
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
<a href="/" class="rss-a">← Grid</a>
</div>
</div>
</header>
<main>
<div class="grid-wrap">
{{range .Groups}}
<section class="tl-day">
<h2 class="tl-date">{{.Label}}<span class="tl-count">{{.Count}}</span></h2>
<div class="pgrid">
{{range .Photos}}
<a href="/photo/{{.Slug}}/" class="ptile">
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
</a>
{{end}}
</div>
</section>
{{end}}
</div>
</main>
<footer class="site-footer">
© 2000{{.Year}} {{.Author}}. All rights reserved.
</footer>
</body>
</html>