Commit ca9bbda518
ca9bbda51857d61220bb9fb8b2d05ffb70bd04f0
parent: 7704b61ebd
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-26 00:05 UTC
Add justified row layout and photo navigation script
Layout: unified · split
internal/web/assets/gallery.js
added
+47
| @@ -0,0 +1,47 @@ |
| |
1 | (() => { |
| |
2 | // Justified rows: fill each row to the container width at roughly --row-height. |
| |
3 | const layout = (el) => { |
| |
4 | const cs = getComputedStyle(el); |
| |
5 | const gap = parseFloat(cs.columnGap) || 0; |
| |
6 | const target = parseFloat(cs.getPropertyValue('--row-height')) || 320; |
| |
7 | const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); |
| |
8 | let row = [], sum = 0; |
| |
9 | const flush = (last) => { |
| |
10 | let h = (width - gap * (row.length - 1)) / sum; |
| |
11 | if (last && h > target) h = target; |
| |
12 | for (const t of row) { |
| |
13 | t.style.width = Math.floor(h * t.dataset.ar) + 'px'; |
| |
14 | t.style.height = Math.floor(h) + 'px'; |
| |
15 | } |
| |
16 | row = []; |
| |
17 | sum = 0; |
| |
18 | }; |
| |
19 | for (const t of el.children) { |
| |
20 | row.push(t); |
| |
21 | sum += +t.dataset.ar; |
| |
22 | if ((width - gap * (row.length - 1)) / sum <= target) flush(false); |
| |
23 | } |
| |
24 | if (row.length) flush(true); |
| |
25 | }; |
| |
26 | const layoutAll = () => document.querySelectorAll('.rows').forEach(layout); |
| |
27 | let timer; |
| |
28 | addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(layoutAll, 100); }); |
| |
29 | layoutAll(); |
| |
30 | |
| |
31 | const follow = (sel) => { const a = document.querySelector(sel); if (a) location.href = a.href; }; |
| |
32 | addEventListener('keydown', (e) => { |
| |
33 | if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; |
| |
34 | if (e.key === 'ArrowLeft') follow('a[rel=prev]'); |
| |
35 | else if (e.key === 'ArrowRight') follow('a[rel=next]'); |
| |
36 | else if (e.key === 'Escape') follow('#up'); |
| |
37 | }); |
| |
38 | |
| |
39 | let x0 = null; |
| |
40 | addEventListener('touchstart', (e) => { x0 = e.touches[0].clientX; }, { passive: true }); |
| |
41 | addEventListener('touchend', (e) => { |
| |
42 | if (x0 === null) return; |
| |
43 | const dx = e.changedTouches[0].clientX - x0; |
| |
44 | x0 = null; |
| |
45 | if (Math.abs(dx) > 50) follow(dx > 0 ? 'a[rel=prev]' : 'a[rel=next]'); |
| |
46 | }, { passive: true }); |
| |
47 | })(); |
internal/web/server.go
+10
| @@ -23,6 +23,9 @@ var embeddedTemplates embed.FS |
| 23 | //go:embed assets/theme.css |
23 | //go:embed assets/theme.css |
| 24 | var embeddedTheme []byte |
24 | var embeddedTheme []byte |
| 25 | |
25 | |
| |
26 | //go:embed assets/gallery.js |
| |
27 | var embeddedScript []byte |
| |
28 | |
| 26 | type Options struct { |
29 | type Options struct { |
| 27 | Title string |
30 | Title string |
| 28 | ThemePath string // replaces the embedded theme.css |
31 | ThemePath string // replaces the embedded theme.css |
| @@ -81,6 +84,7 @@ func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler |
| 81 | mux.HandleFunc("GET /{$}", s.home) |
84 | mux.HandleFunc("GET /{$}", s.home) |
| 82 | mux.HandleFunc("GET /info/{$}", s.info) |
85 | mux.HandleFunc("GET /info/{$}", s.info) |
| 83 | mux.HandleFunc("GET /_/theme.css", s.themeCSS) |
86 | mux.HandleFunc("GET /_/theme.css", s.themeCSS) |
| |
87 | mux.HandleFunc("GET /_/gallery.js", s.script) |
| 84 | mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image) |
88 | mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image) |
| 85 | mux.HandleFunc("GET /{album}/{$}", s.album) |
89 | mux.HandleFunc("GET /{album}/{$}", s.album) |
| 86 | mux.HandleFunc("GET /{album}/{file}/{$}", s.photo) |
90 | mux.HandleFunc("GET /{album}/{file}/{$}", s.photo) |
| @@ -187,3 +191,9 @@ func (s *server) themeCSS(w http.ResponseWriter, r *http.Request) { |
| 187 | w.Header().Set("Cache-Control", "no-cache") |
191 | w.Header().Set("Cache-Control", "no-cache") |
| 188 | w.Write(s.theme) |
192 | w.Write(s.theme) |
| 189 | } |
193 | } |
| |
194 | |
| |
195 | func (s *server) script(w http.ResponseWriter, r *http.Request) { |
| |
196 | w.Header().Set("Content-Type", "text/javascript; charset=utf-8") |
| |
197 | w.Header().Set("Cache-Control", "no-cache") |
| |
198 | w.Write(embeddedScript) |
| |
199 | } |
internal/web/server_test.go
+11
| @@ -198,3 +198,14 @@ func TestCustomTemplates(t *testing.T) { |
| 198 | t.Fatalf("body = %q", body) |
198 | t.Fatalf("body = %q", body) |
| 199 | } |
199 | } |
| 200 | } |
200 | } |
| |
201 | |
| |
202 | func TestScript(t *testing.T) { |
| |
203 | h := newServer(t, setup{}) |
| |
204 | rec := get(h, "/_/gallery.js") |
| |
205 | if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") { |
| |
206 | t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type")) |
| |
207 | } |
| |
208 | if !strings.Contains(get(h, "/tokyo/").Body.String(), `<script src="/_/gallery.js" defer></script>`) { |
| |
209 | t.Fatal("album page missing script tag") |
| |
210 | } |
| |
211 | } |
internal/web/templates/base.html
+1
| @@ -5,6 +5,7 @@ |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | <title>{{block "title" .}}{{.Title}}{{end}}</title> |
6 | <title>{{block "title" .}}{{.Title}}{{end}}</title> |
| 7 | <link rel="stylesheet" href="/_/theme.css"> |
7 | <link rel="stylesheet" href="/_/theme.css"> |
| |
8 | <script src="/_/gallery.js" defer></script> |
| 8 | </head> |
9 | </head> |
| 9 | <body class="{{block "bodyclass" .}}{{end}}"> |
10 | <body class="{{block "bodyclass" .}}{{end}}"> |
| 10 | <nav class="site"> |
11 | <nav class="site"> |