Add timeline page !6
10 files changed, +112 −9
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 | ||
| 148 | Site title left; album names and Info right, in small spaced capitals. The | 148 | Site title left; album names, Timeline and Info right, in small spaced |
| 149 | current album is highlighted. | 149 | capitals. 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 | |||
| 160 | items as justified rows with a 4 px gap. No lead image. Each tile links to the | 160 | items as justified rows with a 4 px gap. No lead image. Each tile links to the |
| 161 | photo page. | 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 | ** 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. | ||
| 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 | func (l *Library) Album(slug string) *Album { | 51 | func (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. |
| 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 | // 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. |
| 17 | func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) { | 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 | 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 | |||
| 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 | nav.site a:hover, nav.site a.on { color: var(--accent); } | 17 | nav.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 | ||
| 35 | var pageNames = []string{"home", "album", "photo", "info", "error"} | 35 | var pageNames = []string{"home", "album", "photo", "info", "timeline", "error"} |
| 36 | 36 | ||
| 37 | type server struct { | 37 | type 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 | ||
| 129 | func (s *server) timeline(w http.ResponseWriter, r *http.Request) { | ||
| 130 | s.render(w, "timeline", http.StatusOK, page{Current: "timeline"}) | ||
| 131 | } | ||
| 132 | |||
| 128 | func (s *server) album(w http.ResponseWriter, r *http.Request) { | 133 | func (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 | ||
| 187 | func TestCustomTemplates(t *testing.T) { | 187 | func 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 | |||
| 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 | {{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}} | ||