Commit 7704b61ebd
Verified · cmc
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 | } | ||
| 11 | html { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } | ||
| 12 | html, body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); } | ||
| 13 | a { color: inherit; text-decoration: none; } | ||
| 14 | |||
| 15 | nav.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); } | ||
| 16 | nav.site .brand { color: var(--accent); font-weight: 600; margin-right: auto; } | ||
| 17 | nav.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 | |||
| 29 | body.photo { display: flex; flex-direction: column; min-height: 100vh; } | ||
| 30 | body.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 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 14 | var 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 | |||
| 24 | func albumURL(a *library.Album) string { | ||
| 25 | return "/" + url.PathEscape(a.Slug) + "/" | ||
| 26 | } | ||
| 27 | |||
| 28 | func photoURL(it *library.Item) string { | ||
| 29 | return "/" + url.PathEscape(it.Album) + "/" + url.PathEscape(it.Name) + "/" | ||
| 30 | } | ||
| 31 | |||
| 32 | func 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. | ||
| 37 | func 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 | |||
| 52 | func 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 | |||
| 59 | func 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. | ||
| 2 | package web | ||
| 3 | |||
| 4 | import ( | ||
| 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 | ||
| 21 | var embeddedTemplates embed.FS | ||
| 22 | |||
| 23 | //go:embed assets/theme.css | ||
| 24 | var embeddedTheme []byte | ||
| 25 | |||
| 26 | type 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 | |||
| 32 | var pageNames = []string{"home", "album", "photo", "info", "error"} | ||
| 33 | |||
| 34 | type server struct { | ||
| 35 | store *library.Store | ||
| 36 | rend *render.Renderer | ||
| 37 | title string | ||
| 38 | theme []byte | ||
| 39 | pages map[string]*template.Template | ||
| 40 | } | ||
| 41 | |||
| 42 | type 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 | |||
| 55 | func 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 | |||
| 91 | func (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 | |||
| 107 | func (s *server) error(w http.ResponseWriter, code int) { | ||
| 108 | s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)}) | ||
| 109 | } | ||
| 110 | |||
| 111 | func (s *server) home(w http.ResponseWriter, r *http.Request) { | ||
| 112 | s.render(w, "home", http.StatusOK, page{}) | ||
| 113 | } | ||
| 114 | |||
| 115 | func (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 | |||
| 124 | func (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 | |||
| 134 | func (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 | |||
| 156 | func (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 | |||
| 185 | func (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 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 19 | type fakeFormat struct{ fail bool } | ||
| 20 | |||
| 21 | func (fakeFormat) Name() string { return "fake" } | ||
| 22 | func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") } | ||
| 23 | func (fakeFormat) Kind() format.Kind { return format.KindImage } | ||
| 24 | func (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 | } | ||
| 31 | func (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 | |||
| 39 | type setup struct { | ||
| 40 | fail bool | ||
| 41 | about bool | ||
| 42 | opt Options | ||
| 43 | } | ||
| 44 | |||
| 45 | func 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 | |||
| 83 | func 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 | |||
| 89 | func 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 | |||
| 125 | func 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 | |||
| 135 | func 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 | |||
| 166 | func 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 | |||
| 173 | func 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 | |||
| 187 | func 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}} | ||