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
24var embeddedTheme []byte 24var embeddedTheme []byte
25 25
26//go:embed assets/gallery.js
27var embeddedScript []byte
28
26type Options struct { 29type 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
195func (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
202func 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">