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. | |