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