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.
145 145
146** Nav (all pages) 146** Nav (all pages)
147 147
148Site title left; album names and Info right, in small spaced capitals. The 148Site title left; album names, Timeline and Info right, in small spaced
149current album is highlighted. 149capitals. The current page is highlighted.
150 150
151** Home: =/= 151** Home: =/=
152 152
@@ -160,6 +160,15 @@ Title line (title left, year and count right), optional description, then all
160items as justified rows with a 4 px gap. No lead image. Each tile links to the 160items as justified rows with a 4 px gap. No lead image. Each tile links to the
161photo page. 161photo page.
162 162
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
163** Photo: =/<album>/<file>/= 172** Photo: =/<album>/<file>/=
164 173
165- Image fitted to the viewport (=object-fit: contain=), =srcset= over the 174- Image fitted to the viewport (=object-fit: contain=), =srcset= over the
internal/library/library.go +30
@@ -15,9 +15,39 @@ type Library struct {
15 Albums []*Album 15 Albums []*Album
16 About template.HTML 16 About template.HTML
17 Incomplete bool // set when the scan found zero albums or an album directory failed to read 17 Incomplete bool // set when the scan found zero albums or an album directory failed to read
18 Timeline []Month
18 bySlug map[string]*Album 19 bySlug map[string]*Album
19} 20}
20 21
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
21func (l *Library) Album(slug string) *Album { 51func (l *Library) Album(slug string) *Album {
22 return l.bySlug[slug] 52 return l.bySlug[slug]
23} 53}
internal/library/scan.go +2 −1
@@ -11,7 +11,7 @@ import (
11) 11)
12 12
13// Slugs that would collide with fixed routes. 13// 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}
15 15
16// Scan reads every album under root. Unreadable albums and files are logged and skipped. 16// Scan reads every album under root. Unreadable albums and files are logged and skipped.
17func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) { 17func 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)
43 lib.bySlug[name] = a 43 lib.bySlug[name] = a
44 } 44 }
45 sortAlbums(lib.Albums) 45 sortAlbums(lib.Albums)
46 lib.Timeline = buildTimeline(lib.Albums)
46 if len(lib.Albums) == 0 { 47 if len(lib.Albums) == 0 {
47 lib.Incomplete = true 48 lib.Incomplete = true
48 } 49 }
internal/library/scan_test.go +23
@@ -95,6 +95,7 @@ func TestScanSkipsAndSorts(t *testing.T) {
95 "img/x.jpg": "x", 95 "img/x.jpg": "x",
96 "info/x.jpg": "x", 96 "info/x.jpg": "x",
97 "_/x.jpg": "x", 97 "_/x.jpg": "x",
98 "timeline/x.jpg": "x",
98 "empty/notes.txt": "x", 99 "empty/notes.txt": "x",
99 "loose.jpg": "x", 100 "loose.jpg": "x",
100 }) 101 })
@@ -262,3 +263,25 @@ func TestStore(t *testing.T) {
262 t.Fatal("Load after Set") 263 t.Fatal("Load after Set")
263 } 264 }
264} 265}
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; }
17nav.site a:hover, nav.site a.on { color: var(--accent); } 17nav.site a:hover, nav.site a.on { color: var(--accent); }
18 18
19.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); } 19.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); }
21.desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); } 21.desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); }
22 22
23.rows { display: flex; flex-wrap: wrap; gap: var(--gap); padding: 0 var(--gap) var(--gap); } 23.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 {
32 Templates fs.FS // replaces the embedded templates; same file names 32 Templates fs.FS // replaces the embedded templates; same file names
33} 33}
34 34
35var pageNames = []string{"home", "album", "photo", "info", "error"} 35var pageNames = []string{"home", "album", "photo", "info", "timeline", "error"}
36 36
37type server struct { 37type server struct {
38 store *library.Store 38 store *library.Store
@@ -83,6 +83,7 @@ func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler
83 mux := http.NewServeMux() 83 mux := http.NewServeMux()
84 mux.HandleFunc("GET /{$}", s.home) 84 mux.HandleFunc("GET /{$}", s.home)
85 mux.HandleFunc("GET /info/{$}", s.info) 85 mux.HandleFunc("GET /info/{$}", s.info)
86 mux.HandleFunc("GET /timeline/{$}", s.timeline)
86 mux.HandleFunc("GET /_/theme.css", s.themeCSS) 87 mux.HandleFunc("GET /_/theme.css", s.themeCSS)
87 mux.HandleFunc("GET /_/gallery.js", s.script) 88 mux.HandleFunc("GET /_/gallery.js", s.script)
88 mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image) 89 mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image)
@@ -125,6 +126,10 @@ func (s *server) info(w http.ResponseWriter, r *http.Request) {
125 s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"}) 126 s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"})
126} 127}
127 128
129func (s *server) timeline(w http.ResponseWriter, r *http.Request) {
130 s.render(w, "timeline", http.StatusOK, page{Current: "timeline"})
131}
132
128func (s *server) album(w http.ResponseWriter, r *http.Request) { 133func (s *server) album(w http.ResponseWriter, r *http.Request) {
129 lib := s.store.Load() 134 lib := s.store.Load()
130 a := lib.Album(r.PathValue("album")) 135 a := lib.Album(r.PathValue("album"))
internal/web/server_test.go +18 −1
@@ -186,7 +186,7 @@ func TestTheme(t *testing.T) {
186 186
187func TestCustomTemplates(t *testing.T) { 187func TestCustomTemplates(t *testing.T) {
188 dir := t.TempDir() 188 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"} {
190 body := `{{define "content"}}custom-` + name + `{{end}}` 190 body := `{{define "content"}}custom-` + name + `{{end}}`
191 if name == "base" { 191 if name == "base" {
192 body = `{{template "content" .}}` 192 body = `{{template "content" .}}`
@@ -223,3 +223,20 @@ func TestEmptyLibraryBeforeFirstScan(t *testing.T) {
223 } 223 }
224 } 224 }
225} 225}
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 @@
7{{with .Album.Description}}<div class="desc">{{.}}</div>{{end}} 7{{with .Album.Description}}<div class="desc">{{.}}</div>{{end}}
8<div class="rows"> 8<div class="rows">
9{{- range .Album.Items}} 9{{- range .Album.Items}}
10 <a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}"> 10 {{template "tile" .}}
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>
13{{- end}} 11{{- end}}
14</div> 12</div>
15{{end}} 13{{end}}
internal/web/templates/base.html +4
@@ -13,6 +13,7 @@
13 {{- range .Lib.Albums}} 13 {{- range .Lib.Albums}}
14 <a href="{{albumURL .}}"{{if eq .Slug $.Current}} class="on"{{end}}>{{.Title}}</a> 14 <a href="{{albumURL .}}"{{if eq .Slug $.Current}} class="on"{{end}}>{{.Title}}</a>
15 {{- end}} 15 {{- end}}
16 <a href="/timeline/"{{if eq .Current "timeline"}} class="on"{{end}}>Timeline</a>
16 {{- if .Lib.About}} 17 {{- if .Lib.About}}
17 <a href="/info/"{{if eq .Current "info"}} class="on"{{end}}>Info</a> 18 <a href="/info/"{{if eq .Current "info"}} class="on"{{end}}>Info</a>
18 {{- end}} 19 {{- end}}
@@ -20,3 +21,6 @@
20<main>{{template "content" .}}</main> 21<main>{{template "content" .}}</main>
21</body> 22</body>
22</html> 23</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}}