Commit e1533cc535

e1533cc535862b5e7c6136388b2481823d73f014

parent: 4cf3e8d214

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-26 23:05 UTC

Add timeline page

/timeline/ lists every photo newest first, grouped by month, as justified
rows linking to each photo page. Linked from the nav. The album tile moves
to a shared "tile" block in base.html; "timeline" is a reserved slug.

Layout: unified · split

docs/specs/2026-09-25-gallery-design.org +11 −2
@@ -145,8 +145,8 @@ uppercase labels with wide letter-spacing. Photos are never cropped.
145145
146146** Nav (all pages)
147147
148Site title left; album names and Info right, in small spaced capitals. The
149current album is highlighted.
148Site title left; album names, Timeline and Info right, in small spaced
149capitals. The current page is highlighted.
150150
151151** Home: =/=
152152
@@ -160,6 +160,15 @@ Title line (title left, year and count right), optional description, then all
160160items as justified rows with a 4 px gap. No lead image. Each tile links to the
161161photo page.
162162
163** Timeline: =/timeline/=
164
165Every item from every album, newest first, grouped under month headings
166("September 2025", with the item count on the right). Each month's items are
167justified rows linking to the item's photo page; navigation there stays within
168the album. Dates are =Item.Taken= (EXIF date, else file mtime), grouped by
169calendar month. =timeline= is a reserved album slug alongside =img=, =info=
170and =_=.
171
163172** Photo: =/<album>/<file>/=
164173
165174- Image fitted to the viewport (=object-fit: contain=), =srcset= over the
internal/library/library.go +30
@@ -15,9 +15,39 @@ type Library struct {
1515 Albums []*Album
1616 About template.HTML
1717 Incomplete bool // set when the scan found zero albums or an album directory failed to read
18 Timeline []Month
1819 bySlug map[string]*Album
1920}
2021
22// Month groups every item taken in one calendar month, newest first.
23type Month struct {
24 Time time.Time // first instant of the month
25 Items []*Item
26}
27
28// buildTimeline groups all items by the month of Item.Taken, newest month and item first.
29func buildTimeline(albums []*Album) []Month {
30 var items []*Item
31 for _, a := range albums {
32 items = append(items, a.Items...)
33 }
34 sort.SliceStable(items, func(i, j int) bool {
35 if !items[i].Taken.Equal(items[j].Taken) {
36 return items[i].Taken.After(items[j].Taken)
37 }
38 return items[i].Album+"/"+items[i].Name < items[j].Album+"/"+items[j].Name
39 })
40 var months []Month
41 for _, it := range items {
42 start := time.Date(it.Taken.Year(), it.Taken.Month(), 1, 0, 0, 0, 0, time.UTC)
43 if n := len(months); n == 0 || !months[n-1].Time.Equal(start) {
44 months = append(months, Month{Time: start})
45 }
46 months[len(months)-1].Items = append(months[len(months)-1].Items, it)
47 }
48 return months
49}
50
2151func (l *Library) Album(slug string) *Album {
2252 return l.bySlug[slug]
2353}
internal/library/scan.go +2 −1
@@ -11,7 +11,7 @@ import (
1111)
1212
1313// Slugs that would collide with fixed routes.
14var reserved = map[string]bool{"img": true, "info": true, "_": true}
14var reserved = map[string]bool{"img": true, "info": true, "timeline": true, "_": true}
1515
1616// Scan reads every album under root. Unreadable albums and files are logged and skipped.
1717func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) {
@@ -43,6 +43,7 @@ func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error)
4343 lib.bySlug[name] = a
4444 }
4545 sortAlbums(lib.Albums)
46 lib.Timeline = buildTimeline(lib.Albums)
4647 if len(lib.Albums) == 0 {
4748 lib.Incomplete = true
4849 }
internal/library/scan_test.go +23
@@ -95,6 +95,7 @@ func TestScanSkipsAndSorts(t *testing.T) {
9595 "img/x.jpg": "x",
9696 "info/x.jpg": "x",
9797 "_/x.jpg": "x",
98 "timeline/x.jpg": "x",
9899 "empty/notes.txt": "x",
99100 "loose.jpg": "x",
100101 })
@@ -262,3 +263,25 @@ func TestStore(t *testing.T) {
262263 t.Fatal("Load after Set")
263264 }
264265}
266
267func TestTimelineGroupsByMonthNewestFirst(t *testing.T) {
268 root := writeTree(t, map[string]string{
269 "a/x1.jpg": "x", "a/x2.jpg": "x",
270 "b/y1.jpg": "x", "b/y2.jpg": "x",
271 })
272 f := &fakeFormat{meta: map[string]format.Meta{
273 "x1.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC)},
274 "x2.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 5, 10, 12, 0, 0, 0, time.UTC)},
275 "y1.jpg": {Width: 1, Height: 1, Taken: time.Date(2025, 5, 20, 12, 0, 0, 0, time.UTC)},
276 "y2.jpg": {Width: 1, Height: 1, Taken: time.Date(2024, 12, 31, 12, 0, 0, 0, time.UTC)},
277 }}
278 lib := scanWith(t, root, f, nil)
279 var got []string
280 for _, m := range lib.Timeline {
281 got = append(got, m.Time.Format("2006-01")+":"+names(m.Items))
282 }
283 want := "2025-05:y1.jpg,x2.jpg 2025-03:x1.jpg 2024-12:y2.jpg"
284 if strings.Join(got, " ") != want {
285 t.Fatalf("timeline = %s\nwant %s", strings.Join(got, " "), want)
286 }
287}
internal/web/assets/theme.css +1 −1
@@ -17,7 +17,7 @@ nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; }
1717nav.site a:hover, nav.site a.on { color: var(--accent); }
1818
1919.title { display: flex; justify-content: space-between; align-items: baseline; padding: 16px 20px 12px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
20.title h1 { margin: 0; font: inherit; color: var(--fg); }
20.title h1, .title h2 { margin: 0; font: inherit; color: var(--fg); }
2121.desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); }
2222
2323.rows { display: flex; flex-wrap: wrap; gap: var(--gap); padding: 0 var(--gap) var(--gap); }
internal/web/server.go +6 −1
@@ -32,7 +32,7 @@ type Options struct {
3232 Templates fs.FS // replaces the embedded templates; same file names
3333}
3434
35var pageNames = []string{"home", "album", "photo", "info", "error"}
35var pageNames = []string{"home", "album", "photo", "info", "timeline", "error"}
3636
3737type server struct {
3838 store *library.Store
@@ -83,6 +83,7 @@ func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler
8383 mux := http.NewServeMux()
8484 mux.HandleFunc("GET /{$}", s.home)
8585 mux.HandleFunc("GET /info/{$}", s.info)
86 mux.HandleFunc("GET /timeline/{$}", s.timeline)
8687 mux.HandleFunc("GET /_/theme.css", s.themeCSS)
8788 mux.HandleFunc("GET /_/gallery.js", s.script)
8889 mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image)
@@ -125,6 +126,10 @@ func (s *server) info(w http.ResponseWriter, r *http.Request) {
125126 s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"})
126127}
127128
129func (s *server) timeline(w http.ResponseWriter, r *http.Request) {
130 s.render(w, "timeline", http.StatusOK, page{Current: "timeline"})
131}
132
128133func (s *server) album(w http.ResponseWriter, r *http.Request) {
129134 lib := s.store.Load()
130135 a := lib.Album(r.PathValue("album"))
internal/web/server_test.go +18 −1
@@ -186,7 +186,7 @@ func TestTheme(t *testing.T) {
186186
187187func TestCustomTemplates(t *testing.T) {
188188 dir := t.TempDir()
189 for _, name := range []string{"base", "home", "album", "photo", "info", "error"} {
189 for _, name := range []string{"base", "home", "album", "photo", "info", "timeline", "error"} {
190190 body := `{{define "content"}}custom-` + name + `{{end}}`
191191 if name == "base" {
192192 body = `{{template "content" .}}`
@@ -223,3 +223,20 @@ func TestEmptyLibraryBeforeFirstScan(t *testing.T) {
223223 }
224224 }
225225}
226
227func TestTimelinePage(t *testing.T) {
228 h := newServer(t, setup{})
229 rec := get(h, "/timeline/")
230 if rec.Code != 200 {
231 t.Fatalf("code %d", rec.Code)
232 }
233 body := rec.Body.String()
234 for _, want := range []string{"March 2025", `href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `href="/timeline/" class="on"`, `data-ar="1.5000"`, `alt="a.jpg"`} {
235 if !strings.Contains(body, want) {
236 t.Errorf("timeline missing %q", want)
237 }
238 }
239 if !strings.Contains(get(h, "/").Body.String(), `href="/timeline/"`) {
240 t.Error("home nav missing Timeline link")
241 }
242}
internal/web/templates/album.html +1 −3
@@ -7,9 +7,7 @@
77{{with .Album.Description}}<div class="desc">{{.}}</div>{{end}}
88<div class="rows">
99{{- range .Album.Items}}
10 <a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}">
11 <img src="{{imgURL . 960}}" srcset="{{srcset .}}" sizes="(max-width: 600px) 100vw, 33vw" width="{{.Meta.Width}}" height="{{.Meta.Height}}" loading="lazy" alt="{{.Name}}">
12 </a>
10 {{template "tile" .}}
1311{{- end}}
1412</div>
1513{{end}}
internal/web/templates/base.html +4
@@ -13,6 +13,7 @@
1313 {{- range .Lib.Albums}}
1414 <a href="{{albumURL .}}"{{if eq .Slug $.Current}} class="on"{{end}}>{{.Title}}</a>
1515 {{- end}}
16 <a href="/timeline/"{{if eq .Current "timeline"}} class="on"{{end}}>Timeline</a>
1617 {{- if .Lib.About}}
1718 <a href="/info/"{{if eq .Current "info"}} class="on"{{end}}>Info</a>
1819 {{- end}}
@@ -20,3 +21,6 @@
2021<main>{{template "content" .}}</main>
2122</body>
2223</html>
24{{define "tile"}}<a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}">
25 <img src="{{imgURL . 960}}" srcset="{{srcset .}}" sizes="(max-width: 600px) 100vw, 33vw" width="{{.Meta.Width}}" height="{{.Meta.Height}}" loading="lazy" alt="{{.Name}}">
26 </a>{{end}}
internal/web/templates/timeline.html added +16
@@ -0,0 +1,16 @@
1{{define "title"}}Timeline · {{.Title}}{{end}}
2{{define "content"}}
3{{- range .Lib.Timeline}}
4<section>
5<header class="title">
6 <h2>{{.Time.Format "January 2006"}}</h2>
7 <span>{{len .Items}}</span>
8</header>
9<div class="rows">
10{{- range .Items}}
11 {{template "tile" .}}
12{{- end}}
13</div>
14</section>
15{{- end}}
16{{end}}