Commit 9a2b0394fa
Verified · cmc
Layout: unified · split
docs/specs/2026-09-25-gallery-design.org added +238
| @@ -0,0 +1,238 @@ | |||
| 1 | #+title: gallery: v1 design | ||
| 2 | #+date: 2026-09-25 | ||
| 3 | |||
| 4 | * Scope | ||
| 5 | |||
| 6 | A single Go binary that serves a photo gallery from a folder tree. Resized | ||
| 7 | copies are generated on request and cached on disk. Photos and the cache live | ||
| 8 | outside the repository. | ||
| 9 | |||
| 10 | ** In v1 | ||
| 11 | |||
| 12 | - Albums from folders: =photos/<album>/*.{jpg,png}=, optional =album.org= per | ||
| 13 | album. | ||
| 14 | - Pages: home, album, photo. Stable URLs. | ||
| 15 | - On-demand resize at a fixed set of widths, disk cache, =srcset=. | ||
| 16 | - EXIF line: camera, lens, aperture, shutter, ISO, date. | ||
| 17 | - One dark theme driven by CSS variables, overridable at runtime. | ||
| 18 | - Keyboard and swipe navigation on the photo page. | ||
| 19 | - New and changed files appear without a restart. | ||
| 20 | |||
| 21 | ** Deferred | ||
| 22 | |||
| 23 | Private albums, upload, tags and search, RSS, likes and comments, | ||
| 24 | watermarking, admin UI, WebP/AVIF derivatives, HEIC/RAW/video formats. | ||
| 25 | |||
| 26 | * Architecture | ||
| 27 | |||
| 28 | ** Layout | ||
| 29 | |||
| 30 | #+begin_example | ||
| 31 | cmd/gallery/main.go flags, wiring, HTTP server | ||
| 32 | internal/library/ scan photos/ into albums and items; watch for changes | ||
| 33 | internal/format/ Format interface and registry (jpeg, png) | ||
| 34 | internal/render/ derivative cache: lookup, generate, store | ||
| 35 | internal/web/ handlers, html/template pages, embedded CSS/JS | ||
| 36 | #+end_example | ||
| 37 | |||
| 38 | ** Configuration | ||
| 39 | |||
| 40 | Flags, each with an environment fallback: | ||
| 41 | |||
| 42 | | Flag | Env | Default | Purpose | | ||
| 43 | |--------------+---------------------+-----------+--------------------------------| | ||
| 44 | | =-photos= | =GALLERY_PHOTOS= | required | photo root, read-only | | ||
| 45 | | =-cache= | =GALLERY_CACHE= | required | derivative and metadata cache | | ||
| 46 | | =-addr= | =GALLERY_ADDR= | =:8080= | listen address | | ||
| 47 | | =-theme= | =GALLERY_THEME= | embedded | replacement =theme.css= | | ||
| 48 | | =-templates= | =GALLERY_TEMPLATES= | embedded | replacement template directory | | ||
| 49 | | =-title= | =GALLERY_TITLE= | =gallery= | site name in the nav | | ||
| 50 | |||
| 51 | At startup the binary checks that =magick= and =exiftool= are on =PATH= and | ||
| 52 | exits with a message naming whichever is missing. | ||
| 53 | |||
| 54 | ** Formats | ||
| 55 | |||
| 56 | #+begin_src go | ||
| 57 | type Kind int | ||
| 58 | |||
| 59 | const ( | ||
| 60 | KindImage Kind = iota | ||
| 61 | // KindVideo later; selects a different template partial. | ||
| 62 | ) | ||
| 63 | |||
| 64 | type Meta struct { | ||
| 65 | Width, Height int // after orientation is applied | ||
| 66 | Taken time.Time | ||
| 67 | Camera, Lens string | ||
| 68 | Aperture string | ||
| 69 | Shutter string | ||
| 70 | ISO int | ||
| 71 | } | ||
| 72 | |||
| 73 | type Format interface { | ||
| 74 | Name() string | ||
| 75 | Match(path string) bool | ||
| 76 | Metadata(path string) (Meta, error) | ||
| 77 | Resize(src string, width int, dst io.Writer) error | ||
| 78 | Kind() Kind | ||
| 79 | } | ||
| 80 | #+end_src | ||
| 81 | |||
| 82 | - A package-level registry holds formats in registration order; the first | ||
| 83 | =Match= wins. Files no format matches are ignored. | ||
| 84 | - v1 registers =jpeg= (=.jpg=, =.jpeg=) and =png=, matched by extension, | ||
| 85 | case-insensitive. | ||
| 86 | - Both share one ImageMagick-backed implementation parameterised by extensions: | ||
| 87 | - =Metadata=: =exiftool -json -n= with the fields above. Width and height are | ||
| 88 | swapped when the EXIF orientation is 5-8. | ||
| 89 | - =Resize=: =magick <src> -auto-orient -resize <w>x -strip -quality 82 jpg:-= | ||
| 90 | streamed to =dst=. Output is always JPEG in v1. | ||
| 91 | - Adding a format (HEIC, AVIF, WebP, RAW) is a new registration. Adding video | ||
| 92 | is a new =Kind= plus a template partial. Neither touches =library= or | ||
| 93 | =render=. | ||
| 94 | |||
| 95 | ** Library | ||
| 96 | |||
| 97 | - Each immediate subdirectory of =-photos= is an album; the slug is the | ||
| 98 | directory name. Nested directories are ignored in v1. Hidden files and | ||
| 99 | directories are skipped. | ||
| 100 | - =album.org= (optional) supplies keywords: | ||
| 101 | #+begin_example | ||
| 102 | ,#+title: Tokyo | ||
| 103 | ,#+date: 2025-03 | ||
| 104 | ,#+cover: DSCF0412.jpg | ||
| 105 | ,#+order: 10 | ||
| 106 | ,#+sort: taken | ||
| 107 | #+end_example | ||
| 108 | The body, if any, is album description text rendered from org to HTML. | ||
| 109 | - =title= defaults to the slug. | ||
| 110 | - =cover= defaults to the first item after sorting. | ||
| 111 | - =order= sorts albums ascending on the home page. Albums without it follow, | ||
| 112 | newest =date= (else newest item taken time) first. | ||
| 113 | - =sort= is =taken= (default) or =name=. | ||
| 114 | - =photos/about.org= (optional) is rendered as the Info page. When absent, the | ||
| 115 | Info link is hidden. | ||
| 116 | - Metadata is cached in =<cache>/meta.json=, keyed by relative path, mtime and | ||
| 117 | size. Only new or changed files are passed to =exiftool=. | ||
| 118 | - The library is an immutable snapshot swapped atomically behind an | ||
| 119 | =atomic.Pointer=. =fsnotify= watches the root and each album directory; | ||
| 120 | events are debounced (500 ms) and trigger a rescan of the affected album, or | ||
| 121 | of the root when albums are added or removed. | ||
| 122 | - A file that fails metadata extraction is logged and excluded. | ||
| 123 | |||
| 124 | ** Rendering and cache | ||
| 125 | |||
| 126 | - URL: =/img/<album>/<file>/<width>.jpg=. | ||
| 127 | - Allowed widths: 480, 960, 1600, 2400. Anything else is 400. Widths larger | ||
| 128 | than the source are clamped to the source width. | ||
| 129 | - Cache path: =<cache>/img/<album>/<file>/<mtime>-<size>/<width>.jpg=. A | ||
| 130 | changed source gets a new directory; stale directories are removed on the | ||
| 131 | next rescan. | ||
| 132 | - Concurrent requests for the same key are collapsed with =singleflight=. | ||
| 133 | - Output is written to a temp file in the target directory and renamed into | ||
| 134 | place, so a failed or interrupted resize never leaves a partial file. | ||
| 135 | - A process-wide semaphore caps concurrent =magick= runs at =runtime.NumCPU()=. | ||
| 136 | - Responses carry =Cache-Control: public, max-age=31536000, immutable=; the URL | ||
| 137 | changes when the source does because pages link with a =?v=<mtime>= query. | ||
| 138 | |||
| 139 | * Pages | ||
| 140 | |||
| 141 | ** Visual direction | ||
| 142 | |||
| 143 | Dark, minimal, cinematic: near-black background, light grey text, small | ||
| 144 | uppercase labels with wide letter-spacing. Photos are never cropped. | ||
| 145 | |||
| 146 | ** Nav (all pages) | ||
| 147 | |||
| 148 | Site title left; album names and Info right, in small spaced capitals. The | ||
| 149 | current album is highlighted. | ||
| 150 | |||
| 151 | ** Home: =/= | ||
| 152 | |||
| 153 | Album covers laid out as justified rows (same algorithm as the album page), one | ||
| 154 | cover per album, with album title and year in small capitals below each. Covers | ||
| 155 | link to the album. | ||
| 156 | |||
| 157 | ** Album: =/<album>/= | ||
| 158 | |||
| 159 | 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 | ||
| 161 | photo page. | ||
| 162 | |||
| 163 | ** Photo: =/<album>/<file>/= | ||
| 164 | |||
| 165 | - Image fitted to the viewport (=object-fit: contain=), =srcset= over the | ||
| 166 | allowed widths. | ||
| 167 | - Previous and next arrows at the sides; =←= =→= keys and horizontal swipe | ||
| 168 | navigate; =Esc= returns to the album. First and last items do not wrap. | ||
| 169 | - Bottom line: title or filename with position (=12 / 24=) on the left, EXIF | ||
| 170 | line on the right. Missing EXIF fields are omitted. | ||
| 171 | - The next and previous images are preloaded. | ||
| 172 | |||
| 173 | ** Justified rows | ||
| 174 | |||
| 175 | - The server emits each item's aspect ratio as =--ar= and =flex-grow= inline. | ||
| 176 | - Base CSS: =display: flex; flex-wrap: wrap;= with each tile's width set from | ||
| 177 | =--row-height × --ar=, so layout is uncropped and stable without JS. | ||
| 178 | - A script (target: under 50 lines) partitions items into rows whose summed | ||
| 179 | aspect ratios fill the container at =--row-height=, sets exact heights, and | ||
| 180 | recomputes on resize (debounced). The last row is not stretched. | ||
| 181 | - Width and height attributes are set on every =<img>= to prevent layout shift. | ||
| 182 | |||
| 183 | ** Theme | ||
| 184 | |||
| 185 | =theme.css= is embedded and replaceable with =-theme=. Variables: | ||
| 186 | |||
| 187 | | Variable | Default | | ||
| 188 | |----------------+-----------------------------------| | ||
| 189 | | =--bg= | =#0a0a0a= | | ||
| 190 | | =--fg= | =#dddddd= | | ||
| 191 | | =--muted= | =#777777= | | ||
| 192 | | =--accent= | =#ffffff= | | ||
| 193 | | =--gap= | =4px= | | ||
| 194 | | =--row-height= | =320px= | | ||
| 195 | | =--font= | Helvetica Neue, Arial, sans-serif | | ||
| 196 | | =--tracking= | =0.25em= | | ||
| 197 | |||
| 198 | Templates (=base=, =home=, =album=, =photo=, =info=, =error=) are embedded and | ||
| 199 | replaceable with =-templates=. | ||
| 200 | |||
| 201 | ** Org rendering | ||
| 202 | |||
| 203 | =album.org= bodies and =about.org= are rendered with | ||
| 204 | =github.com/niklasfasching/go-org=. | ||
| 205 | |||
| 206 | * Errors | ||
| 207 | |||
| 208 | | Condition | Result | | ||
| 209 | |-------------------------------------+------------------------------------------| | ||
| 210 | | Unknown album or file | 404, styled error page | | ||
| 211 | | Width not in the allowed set | 400 | | ||
| 212 | | =magick= fails | 500, logged, nothing cached | | ||
| 213 | | =exiftool= fails for a file | file excluded, logged | | ||
| 214 | | Malformed =album.org= | defaults used, logged | | ||
| 215 | | =magick= or =exiftool= missing | exit at startup with the missing name | | ||
| 216 | | Path traversal in any URL segment | 404; slugs are validated against the library, never joined raw | | ||
| 217 | |||
| 218 | * Deployment | ||
| 219 | |||
| 220 | - Multi-stage =Dockerfile=: =golang:1= builds a static binary; | ||
| 221 | =alpine= runtime with =imagemagick=, =imagemagick-heic=, =imagemagick-webp=, | ||
| 222 | =imagemagick-jpeg=, and =exiftool=. | ||
| 223 | - =compose.yml= in =~/docker/gallery= on =homelab=: | ||
| 224 | - =127.0.0.1:8003:8080= | ||
| 225 | - photos mounted read-only at =/photos=, cache read-write at =/cache= | ||
| 226 | - Host nginx proxies to =127.0.0.1:8003=, matching the existing services. | ||
| 227 | |||
| 228 | * Testing | ||
| 229 | |||
| 230 | - =library=: scanning, =album.org= parsing and defaults, sort orders, hidden | ||
| 231 | and unmatched files skipped, metadata cache invalidation on mtime/size. | ||
| 232 | - =format=: registry matching and precedence. | ||
| 233 | - =render=: width validation and clamping, cache path derivation, | ||
| 234 | =singleflight= collapse, no partial files on failure. | ||
| 235 | - Integration, skipped when =magick= or =exiftool= is absent: fixture JPEG, | ||
| 236 | PNG, and an orientation-6 JPEG through real =Metadata= and =Resize=, checking | ||
| 237 | dimensions and orientation. | ||
| 238 | - HTTP: =httptest= against every route and every row in the errors table. | ||