Commit 7704b61ebd

7704b61ebd74d41a15ba29ca382209508b65d60f

parent: 5ce5d2c9ae

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-26 00:01 UTC

Add web handlers, templates and theme

Layout: unified · split

internal/web/assets/theme.css added +47
@@ -0,0 +1,47 @@
1:root {
2 --bg: #0a0a0a;
3 --fg: #dddddd;
4 --muted: #777777;
5 --accent: #ffffff;
6 --gap: 4px;
7 --row-height: 320px;
8 --font: "Helvetica Neue", Arial, sans-serif;
9 --tracking: 0.25em;
10}
11html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; }
12html, body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); }
13a { color: inherit; text-decoration: none; }
14
15nav.site { display: flex; flex-wrap: wrap; gap: 8px 22px; padding: 18px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
16nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; }
17nav.site a:hover, nav.site a.on { color: var(--accent); }
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); }
20.title h1 { margin: 0; font: inherit; color: var(--fg); }
21.desc { padding: 0 20px 16px; max-width: 60ch; color: var(--muted); }
22
23.rows { display: flex; flex-wrap: wrap; gap: var(--gap); padding: 0 var(--gap) var(--gap); }
24.tile { display: block; position: relative; height: var(--row-height); width: calc(var(--row-height) * var(--ar)); max-width: 100%; }
25.tile img { display: block; width: 100%; height: 100%; object-fit: contain; }
26.captioned { row-gap: 36px; padding-top: 4px; padding-bottom: 40px; }
27.captioned .label { position: absolute; top: 100%; left: 0; padding-top: 8px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); white-space: nowrap; }
28
29body.photo { display: flex; flex-direction: column; min-height: 100vh; }
30body.photo main { flex: 1; display: flex; flex-direction: column; }
31.stage { flex: 1; margin: 0; display: flex; align-items: center; justify-content: center; position: relative; padding: 0 48px; }
32.stage img { max-width: 100%; max-height: calc(100vh - 120px); width: auto; height: auto; }
33.stage .prev, .stage .next { position: absolute; top: 0; bottom: 0; width: 48px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: var(--muted); }
34.stage .prev { left: 0; }
35.stage .next { right: 0; }
36.stage .prev:hover, .stage .next:hover { color: var(--accent); }
37.meta { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px 20px 20px; font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
38.meta span:first-child { color: var(--fg); }
39
40.info { padding: 0 20px 40px; max-width: 60ch; }
41.error { padding: 40px 20px; font-size: 10px; letter-spacing: var(--tracking); text-transform: uppercase; color: var(--muted); }
42
43@media (max-width: 600px) {
44 :root { --row-height: 200px; }
45 .stage { padding: 0; }
46 .stage .prev, .stage .next { display: none; }
47}
internal/web/funcs.go added +71
@@ -0,0 +1,71 @@
1package web
2
3import (
4 "fmt"
5 "html/template"
6 "net/url"
7 "strconv"
8 "strings"
9
10 "gitbay.org/cmc/gallery/internal/library"
11 "gitbay.org/cmc/gallery/internal/render"
12)
13
14var funcs = template.FuncMap{
15 "albumURL": albumURL,
16 "photoURL": photoURL,
17 "imgURL": imgURL,
18 "srcset": srcset,
19 "ar": aspect,
20 "exif": exifLine,
21 "add": func(a, b int) int { return a + b },
22}
23
24func albumURL(a *library.Album) string {
25 return "/" + url.PathEscape(a.Slug) + "/"
26}
27
28func photoURL(it *library.Item) string {
29 return "/" + url.PathEscape(it.Album) + "/" + url.PathEscape(it.Name) + "/"
30}
31
32func imgURL(it *library.Item, w int) string {
33 return fmt.Sprintf("/img/%s/%s/%d.jpg?v=%d", url.PathEscape(it.Album), url.PathEscape(it.Name), w, it.ModTime.Unix())
34}
35
36// srcset lists allowed widths up to the first one that reaches the source width.
37func srcset(it *library.Item) string {
38 var parts []string
39 for _, w := range render.Widths {
40 actual := w
41 if it.Meta.Width > 0 && w >= it.Meta.Width {
42 actual = it.Meta.Width
43 }
44 parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual))
45 if actual != w {
46 break
47 }
48 }
49 return strings.Join(parts, ", ")
50}
51
52func aspect(it *library.Item) string {
53 if it.Meta.Height == 0 {
54 return "1"
55 }
56 return strconv.FormatFloat(float64(it.Meta.Width)/float64(it.Meta.Height), 'f', 4, 64)
57}
58
59func exifLine(it *library.Item) string {
60 m := it.Meta
61 var parts []string
62 for _, s := range []string{m.Camera, m.Lens, m.Aperture, m.Shutter} {
63 if s != "" {
64 parts = append(parts, s)
65 }
66 }
67 if m.ISO > 0 {
68 parts = append(parts, "ISO "+strconv.Itoa(m.ISO))
69 }
70 return strings.Join(parts, " · ")
71}
internal/web/server.go added +189
@@ -0,0 +1,189 @@
1// Package web serves gallery pages and images.
2package web
3
4import (
5 "bytes"
6 "embed"
7 "fmt"
8 "html/template"
9 "io/fs"
10 "log"
11 "net/http"
12 "os"
13 "strconv"
14 "strings"
15
16 "gitbay.org/cmc/gallery/internal/library"
17 "gitbay.org/cmc/gallery/internal/render"
18)
19
20//go:embed templates/*.html
21var embeddedTemplates embed.FS
22
23//go:embed assets/theme.css
24var embeddedTheme []byte
25
26type Options struct {
27 Title string
28 ThemePath string // replaces the embedded theme.css
29 Templates fs.FS // replaces the embedded templates; same file names
30}
31
32var pageNames = []string{"home", "album", "photo", "info", "error"}
33
34type server struct {
35 store *library.Store
36 rend *render.Renderer
37 title string
38 theme []byte
39 pages map[string]*template.Template
40}
41
42type page struct {
43 Title string
44 Lib *library.Library
45 Current string
46 Album *library.Album
47 Item *library.Item
48 Index int
49 Prev *library.Item
50 Next *library.Item
51 Code int
52 Message string
53}
54
55func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error) {
56 s := &server{store: store, rend: rend, title: opt.Title, theme: embeddedTheme, pages: map[string]*template.Template{}}
57 if opt.ThemePath != "" {
58 b, err := os.ReadFile(opt.ThemePath)
59 if err != nil {
60 return nil, err
61 }
62 s.theme = b
63 }
64 tfs := opt.Templates
65 if tfs == nil {
66 sub, err := fs.Sub(embeddedTemplates, "templates")
67 if err != nil {
68 return nil, err
69 }
70 tfs = sub
71 }
72 for _, name := range pageNames {
73 t, err := template.New("base.html").Funcs(funcs).ParseFS(tfs, "base.html", name+".html")
74 if err != nil {
75 return nil, fmt.Errorf("template %s: %w", name, err)
76 }
77 s.pages[name] = t
78 }
79
80 mux := http.NewServeMux()
81 mux.HandleFunc("GET /{$}", s.home)
82 mux.HandleFunc("GET /info/{$}", s.info)
83 mux.HandleFunc("GET /_/theme.css", s.themeCSS)
84 mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image)
85 mux.HandleFunc("GET /{album}/{$}", s.album)
86 mux.HandleFunc("GET /{album}/{file}/{$}", s.photo)
87 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.error(w, http.StatusNotFound) })
88 return mux, nil
89}
90
91func (s *server) render(w http.ResponseWriter, name string, status int, p page) {
92 p.Title = s.title
93 if p.Lib == nil {
94 p.Lib = s.store.Load()
95 }
96 var buf bytes.Buffer
97 if err := s.pages[name].Execute(&buf, p); err != nil {
98 log.Printf("render %s: %v", name, err)
99 http.Error(w, "internal error", http.StatusInternalServerError)
100 return
101 }
102 w.Header().Set("Content-Type", "text/html; charset=utf-8")
103 w.WriteHeader(status)
104 buf.WriteTo(w)
105}
106
107func (s *server) error(w http.ResponseWriter, code int) {
108 s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)})
109}
110
111func (s *server) home(w http.ResponseWriter, r *http.Request) {
112 s.render(w, "home", http.StatusOK, page{})
113}
114
115func (s *server) info(w http.ResponseWriter, r *http.Request) {
116 lib := s.store.Load()
117 if lib.About == "" {
118 s.error(w, http.StatusNotFound)
119 return
120 }
121 s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"})
122}
123
124func (s *server) album(w http.ResponseWriter, r *http.Request) {
125 lib := s.store.Load()
126 a := lib.Album(r.PathValue("album"))
127 if a == nil {
128 s.error(w, http.StatusNotFound)
129 return
130 }
131 s.render(w, "album", http.StatusOK, page{Lib: lib, Current: a.Slug, Album: a})
132}
133
134func (s *server) photo(w http.ResponseWriter, r *http.Request) {
135 lib := s.store.Load()
136 a := lib.Album(r.PathValue("album"))
137 if a == nil {
138 s.error(w, http.StatusNotFound)
139 return
140 }
141 i, it := a.Item(r.PathValue("file"))
142 if it == nil {
143 s.error(w, http.StatusNotFound)
144 return
145 }
146 p := page{Lib: lib, Current: a.Slug, Album: a, Item: it, Index: i}
147 if i > 0 {
148 p.Prev = a.Items[i-1]
149 }
150 if i < len(a.Items)-1 {
151 p.Next = a.Items[i+1]
152 }
153 s.render(w, "photo", http.StatusOK, p)
154}
155
156func (s *server) image(w http.ResponseWriter, r *http.Request) {
157 lib := s.store.Load()
158 a := lib.Album(r.PathValue("album"))
159 if a == nil {
160 http.NotFound(w, r)
161 return
162 }
163 _, it := a.Item(r.PathValue("file"))
164 if it == nil {
165 http.NotFound(w, r)
166 return
167 }
168 ws, ok := strings.CutSuffix(r.PathValue("width"), ".jpg")
169 n, err := strconv.Atoi(ws)
170 if !ok || err != nil || !render.Allowed(n) {
171 http.Error(w, "bad width", http.StatusBadRequest)
172 return
173 }
174 p, err := s.rend.Get(it, n)
175 if err != nil {
176 log.Printf("render %s/%s@%d: %v", it.Album, it.Name, n, err)
177 http.Error(w, "render failed", http.StatusInternalServerError)
178 return
179 }
180 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
181 w.Header().Set("Content-Type", "image/jpeg")
182 http.ServeFile(w, r, p)
183}
184
185func (s *server) themeCSS(w http.ResponseWriter, r *http.Request) {
186 w.Header().Set("Content-Type", "text/css; charset=utf-8")
187 w.Header().Set("Cache-Control", "no-cache")
188 w.Write(s.theme)
189}
internal/web/server_test.go added +200
@@ -0,0 +1,200 @@
1package web
2
3import (
4 "errors"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "gitbay.org/cmc/gallery/internal/format"
15 "gitbay.org/cmc/gallery/internal/library"
16 "gitbay.org/cmc/gallery/internal/render"
17)
18
19type fakeFormat struct{ fail bool }
20
21func (fakeFormat) Name() string { return "fake" }
22func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") }
23func (fakeFormat) Kind() format.Kind { return format.KindImage }
24func (fakeFormat) Metadata(string) (format.Meta, error) {
25 return format.Meta{
26 Width: 3000, Height: 2000,
27 Taken: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
28 Camera: "X-T5", Aperture: "f/2", Shutter: "1/125", ISO: 1600,
29 }, nil
30}
31func (f fakeFormat) Resize(_ string, _ int, dst io.Writer) error {
32 if f.fail {
33 return errors.New("magick failed")
34 }
35 _, err := io.WriteString(dst, "JPEGDATA")
36 return err
37}
38
39type setup struct {
40 fail bool
41 about bool
42 opt Options
43}
44
45func newServer(t *testing.T, s setup) http.Handler {
46 t.Helper()
47 root := t.TempDir()
48 files := map[string]string{
49 "tokyo/a.jpg": "x",
50 "tokyo/b.jpg": "x",
51 "tokyo/album.org": "#+title: Tokyo\n#+date: 2025-03\n",
52 }
53 if s.about {
54 files["about.org"] = "Hello /there/.\n"
55 }
56 for rel, body := range files {
57 p := filepath.Join(root, rel)
58 os.MkdirAll(filepath.Dir(p), 0o755)
59 os.WriteFile(p, []byte(body), 0o644)
60 }
61 reg := &format.Registry{}
62 reg.Register(fakeFormat{fail: s.fail})
63 cache, err := library.OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
64 if err != nil {
65 t.Fatal(err)
66 }
67 lib, err := library.Scan(root, reg, cache)
68 if err != nil {
69 t.Fatal(err)
70 }
71 store := &library.Store{}
72 store.Set(lib)
73 if s.opt.Title == "" {
74 s.opt.Title = "gallery"
75 }
76 h, err := New(store, render.New(t.TempDir(), 2), s.opt)
77 if err != nil {
78 t.Fatal(err)
79 }
80 return h
81}
82
83func get(h http.Handler, path string) *httptest.ResponseRecorder {
84 rec := httptest.NewRecorder()
85 h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
86 return rec
87}
88
89func TestPages(t *testing.T) {
90 h := newServer(t, setup{about: true})
91 for _, tc := range []struct {
92 path string
93 code int
94 want []string
95 reject []string
96 }{
97 {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil},
98 {"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`}, nil},
99 {"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600", `rel="next" href="/tokyo/b.jpg/"`, `id="up" href="/tokyo/"`}, []string{`rel="prev"`}},
100 {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}},
101 {"/info/", 200, []string{"<em>there</em>"}, nil},
102 {"/nope/", 404, []string{"404"}, nil},
103 {"/tokyo/nope.jpg/", 404, []string{"404"}, nil},
104 {"/tokyo/a.jpg/extra/", 404, nil, nil},
105 } {
106 rec := get(h, tc.path)
107 if rec.Code != tc.code {
108 t.Errorf("%s: code %d, want %d", tc.path, rec.Code, tc.code)
109 continue
110 }
111 body := rec.Body.String()
112 for _, w := range tc.want {
113 if !strings.Contains(body, w) {
114 t.Errorf("%s: missing %q", tc.path, w)
115 }
116 }
117 for _, r := range tc.reject {
118 if strings.Contains(body, r) {
119 t.Errorf("%s: unexpected %q", tc.path, r)
120 }
121 }
122 }
123}
124
125func TestInfoHiddenWithoutAbout(t *testing.T) {
126 h := newServer(t, setup{})
127 if strings.Contains(get(h, "/").Body.String(), `href="/info/"`) {
128 t.Error("nav links Info without about.org")
129 }
130 if code := get(h, "/info/").Code; code != 404 {
131 t.Errorf("/info/ = %d, want 404", code)
132 }
133}
134
135func TestImages(t *testing.T) {
136 h := newServer(t, setup{})
137 rec := get(h, "/img/tokyo/a.jpg/960.jpg?v=1")
138 if rec.Code != 200 || rec.Body.String() != "JPEGDATA" {
139 t.Fatalf("code %d body %q", rec.Code, rec.Body.String())
140 }
141 if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
142 t.Errorf("Cache-Control = %q", cc)
143 }
144 if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
145 t.Errorf("Content-Type = %q", ct)
146 }
147 for path, code := range map[string]int{
148 "/img/tokyo/a.jpg/961.jpg": 400,
149 "/img/tokyo/a.jpg/960.png": 400,
150 "/img/tokyo/a.jpg/big.jpg": 400,
151 "/img/nope/a.jpg/960.jpg": 404,
152 "/img/tokyo/nope.jpg/960.jpg": 404,
153 } {
154 if got := get(h, path).Code; got != code {
155 t.Errorf("%s: %d, want %d", path, got, code)
156 }
157 }
158 // The mux may 404 or redirect these; either way no file is served.
159 for _, path := range []string{"/img/%2e%2e/a.jpg/960.jpg", "/img/tokyo/..%2fa.jpg/960.jpg"} {
160 if rec := get(h, path); rec.Code == 200 {
161 t.Errorf("%s: served %q", path, rec.Body.String())
162 }
163 }
164}
165
166func TestImageRenderFailure(t *testing.T) {
167 h := newServer(t, setup{fail: true})
168 if code := get(h, "/img/tokyo/a.jpg/480.jpg").Code; code != 500 {
169 t.Fatalf("code %d, want 500", code)
170 }
171}
172
173func TestTheme(t *testing.T) {
174 rec := get(newServer(t, setup{}), "/_/theme.css")
175 if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") || !strings.Contains(rec.Body.String(), "--bg") {
176 t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
177 }
178
179 custom := filepath.Join(t.TempDir(), "theme.css")
180 os.WriteFile(custom, []byte(":root{--bg:red}"), 0o644)
181 rec = get(newServer(t, setup{opt: Options{ThemePath: custom}}), "/_/theme.css")
182 if rec.Body.String() != ":root{--bg:red}" {
183 t.Fatalf("custom theme not served: %q", rec.Body.String())
184 }
185}
186
187func TestCustomTemplates(t *testing.T) {
188 dir := t.TempDir()
189 for _, name := range []string{"base", "home", "album", "photo", "info", "error"} {
190 body := `{{define "content"}}custom-` + name + `{{end}}`
191 if name == "base" {
192 body = `{{template "content" .}}`
193 }
194 os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644)
195 }
196 h := newServer(t, setup{opt: Options{Templates: os.DirFS(dir)}})
197 if body := get(h, "/").Body.String(); body != "custom-home" {
198 t.Fatalf("body = %q", body)
199 }
200}
internal/web/templates/album.html added +15
@@ -0,0 +1,15 @@
1{{define "title"}}{{.Album.Title}} · {{.Title}}{{end}}
2{{define "content"}}
3<header class="title">
4 <h1>{{.Album.Title}}</h1>
5 <span>{{with .Album.Year}}{{.}} · {{end}}{{len .Album.Items}}</span>
6</header>
7{{with .Album.Description}}<div class="desc">{{.}}</div>{{end}}
8<div class="rows">
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="">
12 </a>
13{{- end}}
14</div>
15{{end}}
internal/web/templates/base.html added +21
@@ -0,0 +1,21 @@
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>{{block "title" .}}{{.Title}}{{end}}</title>
7<link rel="stylesheet" href="/_/theme.css">
8</head>
9<body class="{{block "bodyclass" .}}{{end}}">
10<nav class="site">
11 <a class="brand" href="/">{{.Title}}</a>
12 {{- range .Lib.Albums}}
13 <a href="{{albumURL .}}"{{if eq .Slug $.Current}} class="on"{{end}}>{{.Title}}</a>
14 {{- end}}
15 {{- if .Lib.About}}
16 <a href="/info/"{{if eq .Current "info"}} class="on"{{end}}>Info</a>
17 {{- end}}
18</nav>
19<main>{{template "content" .}}</main>
20</body>
21</html>
internal/web/templates/error.html added +2
@@ -0,0 +1,2 @@
1{{define "title"}}{{.Code}} · {{.Title}}{{end}}
2{{define "content"}}<p class="error">{{.Code}} — {{.Message}}</p>{{end}}
internal/web/templates/home.html added +10
@@ -0,0 +1,10 @@
1{{define "content"}}
2<div class="rows captioned">
3{{- range $a := .Lib.Albums}}{{$c := $a.Cover}}
4 <a class="tile" href="{{albumURL $a}}" data-ar="{{ar $c}}" style="--ar:{{ar $c}}">
5 <img src="{{imgURL $c 960}}" srcset="{{srcset $c}}" sizes="(max-width: 600px) 100vw, 50vw" width="{{$c.Meta.Width}}" height="{{$c.Meta.Height}}" loading="lazy" alt="{{$a.Title}}">
6 <span class="label">{{$a.Title}}{{with $a.Year}} · {{.}}{{end}}</span>
7 </a>
8{{- end}}
9</div>
10{{end}}
internal/web/templates/info.html added +2
@@ -0,0 +1,2 @@
1{{define "title"}}Info · {{.Title}}{{end}}
2{{define "content"}}<article class="info">{{.Lib.About}}</article>{{end}}
internal/web/templates/photo.html added +16
@@ -0,0 +1,16 @@
1{{define "title"}}{{.Item.Name}} · {{.Album.Title}} · {{.Title}}{{end}}
2{{define "bodyclass"}}photo{{end}}
3{{define "content"}}
4<figure class="stage">
5 {{with .Prev}}<a class="prev" rel="prev" href="{{photoURL .}}" aria-label="Previous">‹</a>{{end}}
6 <img src="{{imgURL .Item 1600}}" srcset="{{srcset .Item}}" sizes="100vw" width="{{.Item.Meta.Width}}" height="{{.Item.Meta.Height}}" alt="">
7 {{with .Next}}<a class="next" rel="next" href="{{photoURL .}}" aria-label="Next">›</a>{{end}}
8</figure>
9<footer class="meta">
10 <span>{{.Item.Name}} — {{add .Index 1}} / {{len .Album.Items}}</span>
11 <span>{{exif .Item}}</span>
12</footer>
13<a id="up" href="{{albumURL .Album}}" hidden></a>
14{{with .Prev}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}}
15{{with .Next}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}}
16{{end}}