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:
@@ -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`
|
||||
- 3-column square grid → 2-column on mobile, 60 photos per page (`/`, `/page/2/`, …)
|
||||
- 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,6 +131,7 @@ nix build # succeeds
|
||||
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
||||
| `assets.go` | `//go:embed` for templates and static |
|
||||
| `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 |
|
||||
|
||||
@@ -43,6 +43,14 @@ type Photo struct {
|
||||
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 {
|
||||
contentDir string
|
||||
outputDir string
|
||||
@@ -120,6 +128,9 @@ 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
|
||||
}
|
||||
@@ -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 {
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
|
||||
@@ -187,6 +187,34 @@ body {
|
||||
.pager-newer { margin-right: 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 ─────────────────────────────────────── */
|
||||
|
||||
.photo-page {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<p class="handle">{{.Handle}}</p>
|
||||
<p class="pcount"><strong>{{.Total}}</strong> posts</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">
|
||||
<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"/>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user