Skip to content

feat(media): one filesystem-backed image database for parks, rides and the blog - #269

Merged
PArns merged 23 commits into
mainfrom
claude/unified-image-database-ee5q0m
Aug 5, 2026
Merged

feat(media): one filesystem-backed image database for parks, rides and the blog#269
PArns merged 23 commits into
mainfrom
claude/unified-image-database-ee5q0m

Conversation

@PArns

@PArns PArns commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Park photos, ride photos and blog galleries were three separate systems, each with its own folder layout, generator and metadata format. The same photo could not serve two of them, so it was either stored twice or invisible to one side.

Now every image lives in one database at public/media/{collection}/, with a {name}.json sidecar per image.

A collection is storage. The sidecar is the index.

Nothing queries by folder. A Halloween photo of Troy sits in the toverland-halloween collection and still answers getRideImages('toverland', 'troy') — one pool serves the blog, the park pages and the ride pages at once. "Blog image" stops being a category; it was the distinction that forced photos to exist twice.

before after
public/images/parks/{park}/{ride}.jpg public/media/{collection}/{name}.jpg
public/blog/images/{folder}/ + captions.json × 6 locales one sidecar per image, all locales inside
hero-images.ts, hero-images-meta.ts, attraction-images.ts lib/media/manifest*.ts
fs.existsSync probing per request a map lookup in the manifest

Focal points

object-fit: cover crops from the centre by default — that is what cuts the head off the Troy horse in the wide ride card while leaving it intact in the tall one. One point per image now drives both the CSS object-position on every card, background and hero and the offset the build-time 16:9 / 4:3 / 1:1 crops are cut at. Tune it once, every rendition follows.

Caching

Image URLs carry ?v={content hash} over the bytes and the focal point. Without it, retargeting a focal point rewrites a crop's bytes at an unchanged URL, and the CDN plus the optimizer's 1-year rendition cache would serve the old framing indefinitely. A global build id would work but would bust every image cache on every deploy; this token moves only when that image does.

/api/media is a day fresh, a week stale-while-revalidate, with a strong ETag from the content revision.

Search

A build-time inverted index (sorted vocabulary + postings, prefix lookup via binary search) with a substring fallback for mid-word fragments (phobia → Arachnophobia), both diacritic-folded (grungrün). The tokenizer is shared between the generator and the query side, so the two cannot disagree about what a token is — the classic bug where nothing is broken and nothing is found.

Bundles

Split like the blog manifest: @/lib/media (structure + search), @/lib/media/text (37 KB of localized prose), and @/lib/media/herothe only client-safe entry, reading a 21 KB slice, because the hero rotation runs in Client Components.

Admin — /admin/media

Searchable grid whose quick filters are the maintenance backlog: rights unknown, no park, low resolution, no focal point, no alt text. Full sidecar editing, with parks and rides chosen through the catalog picker rather than typed as slugs, and moving an image between collections. Existing images can be upgraded in place — the bytes are swapped, the id, assignment, tags, credit and focal point stay.

The focal-point editor previews through the real components (AttractionCard, ParkCard, BlogPostCardView, ParkBackground) in their open and closed states, with toggles for the chrome that changes how much of the photo survives.

Drag & drop upload reads EXIF to propose placement. What is auto-filled versus offered is measured, not guessed — against the 55 photos carrying both GPS and a known ride:

question nearest match is right so the admin…
which park? 89 % fills it in
which ride? 55 % offers a shortlist
ride in top 3 / 5 / 8 78 % / 87 % / 95 % one click, never auto-assigned

Auto-picking the nearest ride would mislabel nearly half of every batch while looking reviewed. Park and ride stay editable regardless.

Writes go out as draft pull requests, like the blog editor — the database is the repository, and for copyright data being reviewable beats writing in place.

One pull request per editing session

The first save opens a media/session-… branch and a draft PR; every later save commits onto that same PR instead of opening a new one. A session is the branch and its PR, which is what lets the resolver tell four states apart: an open session PR is joined, a branch that never got a PR is adopted, and a branch whose PR was merged or closed is spent, so the next save starts fresh. A failed lookup now refuses the save rather than answering "no session" and silently opening a second PR. 12 stubbed cases in pnpm test:media-session.

Replacing an image is staged rather than fired on drop: the new bytes and the edited sidecar go out as one operation, and the dialog reports back what was written.

Moving an image takes its references with it

Moving is the only edit that changes an image's URL. Retagging does not — park, ride, roles and text are not part of the path, and the version is sha1(bytes + focus), so assigning a park leaves ?v= alone. A move renames the file, and a post still naming the old path renders a 404 with a green build. That is what happened when the duplicates below were merged.

So the commit route now rewrites the affected posts in the same pull request, found through the build-time bodies manifest at no API cost, and the prebuild fails on any /media/ path in an article that the database does not have. The tree can be filed by park without articles paying for it.

Four latent bugs this surfaced

  • toverland/maximus-blitzbahn.jpeg was dead. The old resolver matched a ride by filename; the API slug is maximus-blitz-bahn. That photo never rendered.
  • Two park slugs are not unique (disneyland-park, universal-islands-of-adventure). The Disneyland background turned out to be Paris, not Anaheim.
  • The blog editor's upload validation would have rejected every image once uploads moved into the media tree.
  • The hero rotation ran for everyone (introduced here, caught in review): a missing park slug was answered with the full pool, so the homepage crossfaded through all 55 photos for every visitor instead of holding the one server-rendered image. Fixed by splitting the accessor in two, so "no park" cannot mean "every park".

The data pass

  • Four duplicates merged. Four pairs were byte-identical under two names — the split storage had made that invisible. efteling/symbolica.jpg, movie-park-germany/iron-claw.jpg, phantasialand/winjas-fear.jpg and walibi-holland/yoy-chill.jpg survive; the six posts that named the others were rewritten.
  • Every image is described. 109 of 109 carry alt text and caption in all six locales. They were rewritten out of the register that gives a model away — the same sentence skeleton across a set, an inventory of everything in frame, mood adjectives nobody asked for. The rule is recorded in CLAUDE.md and public/media/README.md.
  • Every image that is ever cropped has a focal point.
  • Blog-editor uploads now write a sidecar with the tags and the alt/caption the author already typed in the editor, in every locale they filled in. Park, ride and credit stay empty on purpose — the admin's filters are where those get worked off, and a guessed value there looks reviewed.

Verification

pnpm release:check green (lint, format, translations, client messages), pnpm build green. test:media 49/49, test:media-session 12/12, test:media-references 9/9, test:blog-image-sidecar 15/15. pnpm check:media-urls walks the rendered site and asserts all 1282 media URLs are known to the database and carry ?v=.

Verified against a running production server: homepage hero, park page, ride page, two blog posts with galleries, glossary, OG cards, the optimizer with the version token at w=128/384/640/1080, /api/media filtering, and zero legacy image paths in any rendered HTML.

Caption migration checked entry-by-entry: 31 images / 372 locale fields before and after.

Notes

  • No redirects for the old image URLs, by request — every in-repo reference was rewritten instead.
  • 109 images moved with git mv, so history follows them.
  • 107 of 109 sources are below the 2048px long-edge target; the "Low resolution" filter in the admin is that backlog, and the replace action is how it gets worked off.
  • 33 gallery images have no focal point. They hold no role, so nothing crops them; the filter lists them anyway.
  • Winja's Force and YOY Thrill lost their own photo to the merge and fall back to their park picture — the cost of not keeping a duplicate for the sake of a filename.

Docs: docs/features/media-database.md and the authoring contract at public/media/README.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn

…d the blog

Park photos, ride photos and blog galleries were three separate systems with
their own folder layout, generator and metadata format. The same photo could
not serve two of them, so it was either stored twice or invisible to one side.

Now every image lives in one database at `public/media/<collection>/`, with a
`<name>.json` sidecar per image. The organizing rule:

    A collection is storage. The sidecar is the index.

Nothing queries by folder. A Halloween photo of Troy sits in the
`toverland-halloween` collection and still answers getRideImages('toverland',
'troy'), so one pool serves the blog, the park pages and the ride pages at once.
"Blog image" stops being a category — it was the distinction that forced photos
to exist twice.

What the sidecar holds: park/ride/area, roles, tags from a controlled vocabulary,
alt and caption for all six locales, credit and licence, capture date, GPS and a
focal point.

Focal points. `object-fit: cover` crops from the centre by default, which cuts
the head off the Troy horse in the wide ride card while leaving it intact in the
tall one. One point per image now drives both the CSS `object-position` on every
card, background and hero, and the offset the build-time 16:9/4:3/1:1 crops are
cut at — tune it once, every rendition follows.

Caching. Image URLs carry `?v=<content hash>` over the bytes and the focal point.
Without it, retargeting a focal point rewrites a crop's bytes at an unchanged URL
and the CDN plus the optimizer's 1-year rendition cache would serve the old
framing indefinitely. A global build id would work but would bust every image
cache on every deploy; this token moves only when that image does.

Search is a build-time inverted index (sorted vocabulary + postings, prefix
lookup via binary search) with a substring fallback for mid-word fragments, both
diacritic-folded. The tokenizer is shared between the generator and the query
side so the two cannot disagree about what a token is.

Bundles are split like the blog manifest: `@/lib/media` for structure and search,
`@/lib/media/text` for the 37 KB of localized prose, and `@/lib/media/hero` — the
only client-safe entry — reading a 21 KB slice, because the hero rotation runs in
Client Components.

Admin (/admin/media): searchable grid whose quick filters are the maintenance
backlog (rights unknown, no park, low resolution, no focal point, no alt text).
Full sidecar editing including moving an image between collections, a focal-point
editor previewing through the real CardPhoto component, and drag & drop upload
that reads EXIF to propose placement. Measured against the 55 photos carrying
both GPS and a known ride: nearest park is right 89% (so it is filled in),
nearest ride only 55% (so it is a distance-ranked shortlist — right in the top 8
for 95% — never auto-assigned). Writes go out as draft pull requests, like the
blog editor: the database is the repository.

Also adds a public API for the app (/api/media, ETag from the content revision,
a day fresh + a week stale-while-revalidate) and 49 tests (pnpm test:media).

Three latent bugs the migration surfaced:
- `toverland/maximus-blitzbahn.jpeg` was dead — the old resolver matched the ride
  by filename and the API slug is `maximus-blitz-bahn`.
- Two park slugs are not unique (`disneyland-park`, `universal-islands-of-adventure`);
  the Disneyland background turned out to be Paris, not Anaheim.
- The blog editor's upload validation would have rejected every image once
  uploads moved into the media tree.

No redirects for the old image URLs, by request; every in-repo reference was
rewritten instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
park.fan Ready Ready Preview Aug 5, 2026 10:20am

Request Review

…n the real cards

Two fixes to the media editor, both from using it.

Park and ride were raw slug text fields. Typing a slug by hand is how a photo
ends up on a ride that does not exist, and there was already a picker for exactly
this — the blog editor's ParkRidePicker, which searches the live catalog. Reusing
it also fills in `parkPath` for free, because the picker returns the full geo
path, which is what disambiguates the two parks whose slug is not unique.

The focal-point previews were wrong. They rendered CardPhoto inside bare
aspect-ratio boxes, but CardPhoto draws a mirrored reflection anchored to the
card's glass-header seam — without that header it came out as a kaleidoscope.
They now render the real AttractionCard, ParkCard and ParkBackground, as tabs,
with toggles for the states that actually change how much of the photo survives:
a second badge row pushes the image down, a short footer gives it back, and a
closed card drops the wait panel entirely. The background tab carries
representative page content so it is visible which part of the photo ends up
behind text.

The cards need next-intl and the admin lives outside `[locale]`, so the previews
supply their own provider — the same approach the blog editor's inline-badge
extension already uses.

No blog-card tab: BlogPostCard resolves its author and category through modules
that read the filesystem, so it cannot be pulled into the admin's client tree.
Previewing it would need a presentational split of that card first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
`heroImageSrcs(parkSlug?)` answered a missing slug with the FULL pool. The in-park
rotation calls it with the slug of the park the visitor was detected in, which is
undefined for everyone not standing in one — so instead of holding the single
server-rendered hero, the homepage crossfaded through all 55 photos from every
park, for every visitor. The function it replaced returned [] for that case.

Split into two functions rather than restoring the optional argument, because the
optional argument is what made the wrong answer look reasonable: `heroImageSrcs()`
is the whole pool (random picks, OG, glossary) and `parkHeroImageSrcs(slug)` is
one park's, empty without a slug.

Also in this commit:

- Existing images can be upgraded. The commit endpoint already supported a
  `replace` op; the editor now has the button for it. The bytes change, the id,
  assignment, tags, credit and focal point stay, and the content hash moves so
  cached renditions of the old file are superseded rather than lingering. This is
  the path for the 110 of 113 sources currently below the 2048px target.
- The blog card is previewable again. `BlogPostCard` was split into a wrapper that
  resolves author and category (both read the filesystem) and `BlogPostCardView`
  that takes them as props. The admin renders the view, so the blog tab shows the
  real card — worth the split, since blog covers are the one surface that crops
  from the centre while park and ride cards crop from the top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
…round visible

Clicking an image in the media browser threw and hit the error boundary. Two
causes, both mine, both in the preview fixtures.

The ride fixture was cast through `unknown` and invented two fields. `ropeDrop`
got `{recommended}` and `bestVisitTimes` got an object — but the real type is
`BestVisitSlot[]`, and the card's guard is `'bestVisitTimes' in attraction &&
attraction.bestVisitTimes`, which a present, truthy object passes. It then called
`.find` on it and threw. The cast is what let this compile: a fixture that has to
lie to the type checker will lie to you at runtime. Both fixtures are now plainly
typed as `ParkAttraction` / `BlogListItem` with no cast, which meant dropping the
two fields whose shapes I had guessed rather than looked up.

The background tab rendered nothing, because `ParkBackground` is `fixed` +
`-z-10` in both its modes — it deliberately escapes every container to sit behind
the page, so it cannot appear in a preview box at all. It gains a `contained`
prop that swaps that shell for an absolute fill; the preview is still the real
component rather than a look-alike.

Also drops the invented wait-time history: it drew an empty chart with nonsense
axis labels, and the card renders its no-data state honestly without it.

Verified in a browser this time, not just in the build — all four tabs render,
both card states, no page errors, focal point visibly applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
The preview grid was a plain `grid-cols-2`. The real cards lay out via
`row-span-3` + `grid-template-rows: subgrid` against an `auto_1fr_auto` parent
(land-section.tsx, and the note in CLAUDE.md), so the preview sized the photo
track by its own row rather than by the card and got the geometry wrong.

Measured, rather than eyeballed: the photo box came out at aspect 1.42 against a
4:3 image, which leaves `object-fit: cover` about 6% of vertical overflow to work
with — dragging the focal point top to bottom moved the picture by a few pixels.
Switching to the real row template first made it worse (aspect 0.93, i.e. taller
than wide, where a 4:3 photo overflows horizontally and the vertical focal point
does nothing at all), so the photo track is pinned to the 220px the card uses as
its minimum, at the 380px width of a card in the 3-column grid.

This does not make the focal point dramatic on ride cards, and that is the honest
result: their photo box is close to square, so a 4:3 photo overflows sideways and
there is little vertical range. Where it does decide the framing is the
build-time 16:9 / 4:3 / 1:1 crops, the 21:9 background and the OG card — all
verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
The focal point had no vertical effect on a card, and the reason was
arithmetic rather than a bug anyone could see. `object-fit: cover` scales an
image to the LARGER of the two ratios it needs, so it overflows on one axis
and fits exactly on the other. The photo was painted across the whole card —
405x404, aspect ~1.0 — so a 4:3 photo filled the card's HEIGHT exactly. With
zero vertical overflow there is nothing for `object-position`'s Y to move, and
dragging the point top to bottom rendered byte-identical pixels. Portrait
photos, being the other way round, always did respond, which is what made it
look intermittent.

So the framing reference is now the strip the two glass panels leave visible,
not the card. `CardPhotoFrame` sits inside the card's photo-spacer row
(405x220, aspect 1.84), which is that strip, and is the layer a visitor sees
and the one the admin tunes; a 4:3 photo overflows it by 84px. `CardPhoto`
still covers the whole card underneath so the frosted panels keep something to
blur. Same URL, so one request and one decode — the second layer costs a
composite, not a download — and their seam falls under a panel whose own
backdrop blur smears it away.

A ride with no live wait time renders no bottom panel, and there its row is
empty rather than covered: the spacer takes `row-span-2` so the framed layer
claims that row too, instead of leaving its lower edge exposed mid-card as a
hard crop seam.

Measured on the running site, ride/park/blog/home, before and after:

  before  Fenix   box 405x404  aspect 1.00 vs image 1.34 —   0px of range
  after   Fenix   box 405x220  aspect 1.84 vs image 1.34 —  84px of range

`pnpm check:card-framing` keeps it that way. It asserts the box of every
panelled card stays wider than 1.5 — not that every photo has range, because a
natively-16:9 picture has none in a 1.67 box and never can. A third badge row
or a taller footer can quietly square the box back up, and nothing else would
notice.

Also here:

- Replacing an image is findable. The control moved out of the right-hand
  column, a screen below the previews, into a bar across the top of the editor
  next to the resolution it fixes; the grid now labels a low-res image
  `1024x768 - replace` instead of marking it with a mute icon.
- The admin previews share one grid with `grid-template-rows: subgrid`, the
  way a real page does, so the closed card keeps the footer row's height
  reserved beneath it rather than collapsing shorter than any page shows.
- Blog covers carry `?v=`. Frontmatter points at a pre-cut crop, and that is
  exactly the file whose bytes get rewritten under an unchanged URL when a
  focal point moves. `getMediaImageForPath` resolves a crop to its source for
  that lookup; `getMediaImageBySrc` deliberately still does not, because its
  callers use the row's own `src` and `width`.
- A pre-cut crop paints centred rather than taking the focal point again — it
  was already cut around that point at build time, and offsetting it twice
  pushes the subject back out of frame.
- Dropped the blog post page's manual cover `<link rel="preload">`: the banner
  renders through next/image, so it preloaded the original while the browser
  fetched the optimized rendition — a second, full-size download of an image
  nothing displayed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
…nd it

Replacing an image was a file picker behind a button. A photo being upgraded
comes out of a file manager, so the whole bar is now the drop target and the
shortest path from "this one is too small" to a pull request is a drag. Clicking
anywhere on it still opens the picker.

Two mechanics that are easy to get wrong, and were:

- The zone is a div with an explicit click, NOT a <label> wrapping the hidden
  input. A label implicitly activates its control, and a drop landing on it
  forwarded that activation — which tore the whole panel down mid-drop.
- `dragleave` checks `relatedTarget`. Without it the highlight flickers off the
  moment the pointer crosses the icon or the copy inside the zone. The upload
  dialog had the same bug and gets the same fix.

A multi-file drop is refused rather than silently using the first: replacing
swaps one file, and picking one for somebody who meant to drop a batch is how
the wrong photo ends up on a ride. Non-images are refused by name.

Verified in a browser against a stubbed admin API: highlight on dragover,
survives crossing a child, clears on a real leave, both refusals, and a single
image drop reaching the commit endpoint as `op: replace` with the right id and
extension.

The editor around it is rebuilt to match.

`MediaDetail` replaced the ENTIRE editor with a bare error panel on any error —
so a save that failed, or now a rejected drop, discarded every unsaved sidecar
edit on screen. Only a failed *load* replaces the panel now; everything after it
renders inline.

The dialog gained a pinned header and footer with a scrolling body, because the
two things you always want reachable were the two furthest apart: the title
scrolled away upward while Save sat at the bottom of a twelve-field column. From
`lg` up the two halves scroll independently — the framing previews are tall, and
one shared scroll pushed the metadata a screenful away from the picture it
describes, which is the pairing this editor exists for. That needs a definite
height down the flex chain; with only `overflow-hidden` the columns silently
clip instead of scrolling, which is what the first attempt did.

Fields are grouped by the question each answers — what it shows, how it is used,
tags, words, rights — instead of twelve flat rows where a park slug carried the
same weight as a caption. Both columns now share one set of primitives
(`panel-ui.tsx`) rather than a card language on one side and bare headings on
the other, and the focal-point image is height-capped so "How it lands" — the
part that answers whether the point is right — is not pushed below the fold by a
portrait original.

Dialog basics that were missing: Escape closes (and closes the catalog picker
first when both are open), the backdrop closes on mousedown-on-itself so a drag
that started inside does not count, body scroll is locked, `role="dialog"` and
`aria-modal` are set. Save is disabled while the draft matches the row, the
header carries an unsaved badge, and closing a dirty draft asks first.

The header thumbnail is cropped at the image's own focal point — the cheapest
possible demonstration of what the setting below it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
claude added 2 commits August 4, 2026 19:50
…goes

Every save opened its own pull request, so retagging a shoot produced a dozen of
them. A session is now **the open pull request whose branch starts with
`media/session-`**: saving looks for one and commits onto its branch, so a working
session is one reviewable PR with a running log in its body.

The state lives in git, not in the browser. A `sessionStorage` id would not
survive a reload, would not be shared with a second tab, and would be a different
session from another machine — while the thing it names, a branch with commits on
it, is right there to be looked up. It ends where it began: merge or close the PR
and the next save opens a new one. "Start a new pull request" in the admin's
session bar is the early exit, and sends `newSession: true`.

`GET /api/admin/media/session` is what that bar reads: which PR, how many changes
are already in it, and whether a token exists at all.

One subtlety worth naming: an operation with no sidecar payload — a `replace` —
has its sidecar rebuilt from the BUILD-TIME manifest, which describes the base
branch. Writing that back would silently undo a sidecar edit made earlier in the
same session, so a rebuilt sidecar is only written when the path is not already on
the branch. Operations that carry a payload send the complete sidecar, so writing
those is always correct.

## The token

"No GitHub token configured" was a dead end: it named no variable, no permissions
and no place to put them. It now says all three, the admin shows the same thing as
a banner before you waste an edit on it, and `.env.example` carries the full setup
— a fine-grained PAT scoped to this repository with **Contents: read & write** and
**Pull requests: read & write**, nothing else.

Verified in a browser against a stubbed admin API: the bar names the open PR and
its change count, a save sends `newSession: false` and reports "added to the open
pull request", "Start a new pull request" flips the flag so the next save sends
`newSession: true`, the token banner names the variable and both permissions, and
nothing is drawn when no session is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
A batch of thirty arrived as thirty rows of a table, and a table reads as
bookkeeping. Park and ride got filled in because the form asked; the fields that
need somebody to actually LOOK at the picture — the focal point, the alt text,
whether it is a night shot — were left for a pass that never happens. The photo
itself was an 80px thumbnail.

So the batch is a queue now. One photo at a time, large enough to judge, with the
EXIF findings beneath it and the ride shortlist as buttons instead of a slug
field. Back / Skip / Next, a progress bar, and ← → S from the keyboard (guarded on
the event target so the same keys still type in the fields).

The focal point is set by clicking the photo, right there. Upload was the one
place it could not be set at all, which is backwards: this is the only moment
every photo in a batch is guaranteed to be in front of someone, and doing it here
costs a click instead of a later trip through the browser. It rides along in the
sidecar payload the commit endpoint already accepts.

Nothing is written until the queue has been walked. The review step keeps the old
table — now with a Revisit button per row and a count of how many will crop from
the centre because nobody set a point — and it is what commits.

Fixed while in there: the review rows called `URL.createObjectURL(files[index])`
during render, minting a new blob URL and leaking the old one on every keystroke
in every field. They are created once per batch and revoked on unmount.

Verified in a browser against a stubbed analyze/commit API, with a real photo as
the upload: the queue advances and counts, S marks a skip, ← → move, the review
warns about missing focal points, and the commit carries 2 of 3 operations with
`focus {x: 0.6984, y: 0.299}` for a click at 70 % / 30 % and the ride's area
filled in from the shortlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
The migration moved all 444 photos to `public/media/**` and left the
Cache-Control rule naming the two trees it emptied:

    source: '/:dir(images|blog|textures)/:path*'

`public/images` and `public/blog` now hold zero files, so the rule covered
nothing but `textures`. Verified against the production build:

    /media/toverland/troy.jpg  →  Cache-Control: public, max-age=0

That is exactly the regression the rule's own comment was written to prevent:
Vercel serves /public with `max-age=0, must-revalidate`, and the Image
Optimization response INHERITS the source's Cache-Control — so every
`/_next/image` hit comes back uncacheable and browsers re-validate every photo on
every page view. `minimumCacheTTL` does not help; it only floors Vercel's own
optimizer cache. It also re-bills transformations.

Every park card, ride card, blog cover, hero and park background was affected.
Adding `media` to the alternation restores `max-age=2678400`, confirmed, and the
`.svg` rule after it still wins its 1-year immutable value for the two diagrams.

`images` and `blog` stay listed rather than being tidied away: a rule that
silently matches nothing is how this got through in the first place, and leaving
the old names there means the next migration trips over them.

Also: `priority` moves to the layer a visitor actually sees. The blog feature
card marked `CardPhoto` — the bleed layer, documented two lines up as "only ever
seen through 16-18px of blur" — as LCP priority, while `CardPhotoFrame`, the
visible strip, stayed lazy. Both render the same URL with the same `sizes`, so it
was one request either way, but the preload belonged on the element whose paint
the reader is waiting for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
The 107 KB media catalog was shipping to nearly every public page. Verified in
the build output before this change:

    .next/static/chunks/1nsealhftsoy9.js   80,548 bytes raw / 10,992 gzip
      → MEDIA_IMAGES verbatim, 352 /media paths
      → <script> in the prerendered homepage HTML

That breaks the rule this branch wrote down in CLAUDE.md, and it did so through
three doors nobody was watching:

1. `lib/media/focus.ts` imports `./index` → `./manifest`. The rule names
   `@/lib/media` and `@/lib/media/text` as forbidden and never mentions `focus`,
   so `objectPositionForSrc` became an unguarded alias for the whole catalog.
   `ParkCard` and `AttractionCard` called it, and seven Client Components import
   those cards — the live hub grid, nearby, favorites, the blog park/ride widgets.
2. `park-card.tsx` had `typeof window === 'undefined' ? require('park-assets')`,
   commented "avoids bundling fs into the client". That was true when park-assets
   walked the filesystem. This branch rewrote it to read the manifest instead, so
   the guard stopped guarding anything: a literal `require()` is statically
   bundled regardless, and park-assets imports `@/lib/media` directly.
3. `land-section.tsx` imported `getAttractionBackgroundImage` and renders inside
   `tabs-with-hash`, a Client Component. This one predates the other two.

The fix is the same in all three: a card is handed its photo and its focal point,
it never looks either up. Resolution moves to where the manifest already lives —
`enrichParksWithImages` / `enrichAttractionsWithImages` now return
`backgroundPosition` alongside `backgroundImage`, `getCardObjectPosition` serves
the direct server call sites, and two API proxies that were passing data straight
through (`/api/discovery`, `/api/parks/[...path]`) enrich it on the way out. That
last one is what lets `land-section` drop its import entirely.

Result, same check as above:

    ✅ catalog is in NO client chunk at all

Proven end to end rather than by inspection — `/api/parks/…/toverland` answers
`troy | 62% 38%`, the focal point from its sidecar, and the card renders it with
no manifest in the browser. Photos survive the live refetch on every surface that
re-renders client-side (country grid 3→3, park attractions 19→19, home 10→10,
blog 10→10), `pnpm test:media` is 49/49 and `check:card-framing` 28/28.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
The upload flow could not have committed three photos, let alone the hundred it
advertised. Both halves put the whole batch in one request — `analyze` as a single
multipart, `commit` as one JSON with every file base64-encoded — and Vercel rejects
request bodies over ~4.5 MB, with base64 adding a third on top. A single 4 MB photo
was already over.

It passed every local test because `next start` has no such limit. The ceiling only
exists in production, which is the worst place to meet it: the first real photo
session would have failed with a platform error naming nothing.

`lib/contribute/config.ts` had already worked this out for visitor uploads, one
request per file with client-side downscaling, and documented the 4.5 MB figure.
The admin now does the same:

- **One request per photo, for both stages.** Body size stops depending on batch
  size. Commits go sequentially, which is also what lets the first one open the
  session pull request and the rest find and join it instead of racing.
- **`analyze` gets the first megabyte** of an oversized original, not the whole
  file. EXIF sits in an APP1 segment right after the JPEG header, so the GPS tag
  and capture date survive without shipping 26 MB to read two numbers.
- **Oversized files are shrunk before commit** via the contribute compressor
  (quality first, resolution only if that is not enough). It re-encodes through a
  canvas and therefore **strips EXIF**, so it runs strictly after `analyze` has
  read the original, and the GPS and capture date are written into the sidecar
  explicitly rather than left for the build to re-read off a file that no longer
  has them.
- `MAX_BYTES` on the server drops from 8 MB to 3.5 MB. Advertising a limit above
  the platform's turns a clear "too large" into an opaque 413.

`Replace file…` posts through the same path and gets the same treatment — it is the
low-res *upgrade* path, so it is the single most likely place to hand over a big
file.

Measured in a browser against a stubbed API, with the request bodies inspected:

    5 × 0.4 MB photos   →  5 analyze requests (max 381 KB)
                           5 commit requests (max 537 KB), 1 operation each
    1 × 25.9 MB photo   →  analyze body 971 KB — the header slice
                           commit body 3,597 KB, under the limit
                           sidecar carries gps {lat: 51.4, lon: 5.98} from the
                           original, after the re-encode dropped it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
Four things a review turned up, all in the same seam.

**A session was allowed to be forgotten.** Resolution looked only at open pull
requests, and swallowed a failed lookup with a console warning. Either way the
next save decided it was the first one and opened a second pull request beside
the running one — repeatedly, so a batch became one PR per image. A session is
now the BRANCH plus its PR: a `media/session-` branch with no open PR (an earlier
save answered 207, or somebody closed the PR and left the branch) is adopted
rather than forked past, and a lookup that fails refuses the save instead of
committing blind. Shared with the banner via `lib/admin/media-session.ts`.

**Replacing a file committed on drop.** So swapping a low-res original and
fixing its caption were two commits, and the first closed the editor. The file
is now staged — thumbnail, `1200×900 → 4000×3000`, size — and goes out with the
next Save in the SAME operation as the sidecar edits. While it is staged the
focal-point editor and the header thumbnail show the incoming picture, which is
the one the focal point and the alt text are actually for. The footer names what
Save will write before you press it, and afterwards the dialog stays open and
says "Uploaded and saved" with the PR link — after a 6 MB upload that is the one
thing worth confirming. The session bar lists what is already in the pull
request: its log lines and the files the branch touches.

**Replacing a `.png` with a `.jpg` deleted the sidecar.** A sidecar path carries
no extension, so `from.sidecar` and `to.sidecar` were the same path, and the move
cleanup removed the file that had been written two lines earlier — dropping the
image out of the database. Only removed when the paths differ.

**The park table was not deterministic.** The API does not promise an order and
"first writer wins" turned that into a coin toss: the same sources regenerated
`universal-islands-of-adventure` from Orlando to Tampa. Sorted by path, so the
generator is a function of its inputs again (and Orlando wins, which is right).

Also from the review — the focal point reached five surfaces as a top crop:
blog ride references, the ride spotlight widget, both hover previews, and the
homepage longest/shortest-wait cards. And `?v=` was missing on every image in an
article body and on hand-listed gallery crops, which is exactly the file whose
bytes are rewritten under an unchanged URL when a focal point moves.

Verified against a production build: the admin dialog drives one `replace`
operation carrying both the bytes and the edited alt text with `newSession:
false`, and nothing is sent on drop; the Troy post renders 3 cards at `62% 38%`
where they were `50% 0%`; 119 of 119 body images carry `?v=`; two consecutive
generator runs produce identical output. `release:check`, `test:media` 49/49 and
`build` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
Pushing straight away is fine — what matters is that the next save lands as
another commit on the pull request that is already open. So the lookup has to
tell four states apart, and it only told two.

  open PR with the prefix        the normal case              → that is it
  branch, never had a PR         commits landed, the PR did    → adopt it
                                 not (the endpoint's 207)
  branch, its PR was MERGED      shipped; GitHub kept the head → start fresh
  branch, its PR was CLOSED      somebody said no              → start fresh

Rows three and four are new, and they are why adopting a dangling branch cannot
be unconditional: committing onto a merged branch opens a pull request whose
diff is everything main gained since, inverted. They are told apart by an exact
`head: owner:branch` lookup with `state: 'all'` — merged and closed PRs are
precisely what a list of open ones cannot see.

The open scan also stopped filtering by base. A session PR retargeted at another
branch is still the session, and filtering it out reports "none running", which
opens a second one — the same failure by a different route.

`pnpm test:media-session` covers all of it against a stubbed GitHub, including
that a failed lookup throws rather than answering null. Twelve cases, because
every way this function can be wrong looks identical from outside: a pull
request per image, noticed only once a batch has scattered across a dozen.

The test harness needed one thing: `server-only` is a Next.js build-time marker,
not an installed package, so the path-alias hook stubs it. The alternative was
dropping the marker from a module that reads the GitHub token, which trades a
real safeguard for a test convenience.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
The close button did nothing while the editor had unsaved changes, and there was
no way out of it — reported with a focal point set on `01-hoehlen`.

`requestClose` gated on `window.confirm`, and a native confirm is not a question
the browser has to ask. An embedded view, a preview pane, or a user who ticked
"prevent this page from creating additional dialogs" all make it return `false`
without showing anything. `false` means "keep editing", so the editor refused to
close, silently, with the X visibly reacting to the click.

Asked in the page now, as an `alertdialog` over the editor, and it lists what is
at stake — "discard the changes" is a much easier decision once it names them
("focal point", "alt text, tags"). Escape walks the stack innermost first: the
catalog picker, then this prompt, then the editor.

Verified against a production build under Playwright, which auto-dismisses native
dialogs and therefore reproduces the reported environment exactly rather than
simulating it: zero native dialogs used, the prompt appears and names the change,
"Keep editing" returns with the draft intact, Escape opens and closes the prompt
without closing the editor, "Discard" closes it, and a clean dialog still closes
in one click with no prompt at all.

The build failed once mid-verification on an upstream API 502 during prerender —
unrelated, green on retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
claude added 4 commits August 5, 2026 08:36
… versioned

A pass over the whole database: 109 images looked at one by one.

**Alt text is now complete** — 109 of 109, six locales each, where 82 had none.
Written to be read, not to be generated: alt is one short factual sentence for
somebody who cannot see the picture, the caption says the thing the picture does
not. The first draft of this ran every entry through the same skeleton (subject,
participle clause, "behind it"), which is exactly what AI-written copy reads
like, and it was rewritten. The rule is now in CLAUDE.md and spelled out with a
worked example in `public/media/README.md`, because it applies to every string a
human sees, not only blog posts.

**Focal points** on every image that is ever cropped: all park backgrounds, all
ride cards, every blog cover. The 33 without one are gallery images carrying no
role — they render at their own aspect ratio and are never cropped, so a focal
point would be a guess with nothing to correct.

**Four images were stored twice**, byte for byte:

  efteling/background.jpg          == efteling/symbolica.jpg
  movie-park-germany/background.jpg == movie-park-germany/iron-claw.jpg
  phantasialand/winjas-fear.jpg    == phantasialand/winjas-force.jpg
  walibi-holland/yoy-chill.jpg     == walibi-holland/yoy-thrill.jpg

Merged to one file each. Roles are a list precisely so one image can be the park
background AND the ride card AND a hero, which is what the first two now are; the
`test:media` assertion that a hero is never a park background encoded the old
assumption and was rewritten to assert what actually matters. The two duelling
coasters are the honest cost: Winja's Force and YOY Thrill now fall back to their
park's photo until they get one of their own. Removing the files broke one gallery
reference and one hard-coded hero path — both caught by the new checker, not by me.

**Every media URL a page renders is content-versioned.** `pnpm check:media-urls`
crawls a running site and asserts it across 1282 URLs on 11 pages; it found five
places that were not: images in article bodies, hand-listed gallery crops, blog
JSON-LD, the RSS enclosure, and the og:image. All of them are files whose bytes
get rewritten under an unchanged URL when a focal point moves, behind a month of
`max-age` and a crawler's own cache on top.

Three hard-coded `/media/...` paths (blog hero, contribute hero, best-time hero)
now ask the database instead, so a park can change which photo that is.

`pnpm audit:media` reports the remaining backlog per image, split into what a
machine can close and what needs a person.

Verified against a production build: 1282/1282 URLs known and versioned, no
duplicate bytes left, `/media` at 31 days and `/api/media` at 1d/7d with a strong
ETag, `test:media` 49/49, `test:media-session` 12/12, `release:check` and `build`
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
…its role

Follow-up to the duplicate merge, which was inconsistent: for Efteling the
descriptive name survived (`symbolica.jpg`) and the generic one went, for Movie
Park it was the other way round. `background.jpg` was kept there only because six
blog posts referenced it, which is a reason to rewrite six references, not a
reason to keep the worse name.

So `movie-park-germany/background.jpg` → `iron-claw.jpg`, carrying its merged
sidecar: `ride: iron-claw` and `park-background` + `ride-card` + `hero`. The six
posts follow. Title and caption updated too — the file was still called "Movie
Park Germany" while being the ride's card.

Nothing resolves a background by filename, which is why this is safe:
`getParkBackground` matches the ROLE. Verified park by park — 9 images carried
`park-background` before, 9 carry it after, one-for-one, and the five parks
without one never had one.

Checked every surface that paints a park photo against a production build:
park page background layer, OG card, server-rendered hub cards, `/api/discovery`
(the live client grid), both branches of `/api/nearby`, `/api/parks/backgrounds`
and the homepage. Efteling resolves to `symbolica.jpg?v=…` with focal point
`50% 30%`, Movie Park to `iron-claw.jpg?v=…` at `42% 30%`, everywhere.

Docs: the naming rule is now written down in `public/media/README.md` (name a file
after what it shows; `background.jpg` stays right where the photo really is
park-level and shows no ride), and the merge is recorded in the media-database doc
with the table of what was merged and the cost — Winja's Force and YOY Thrill fall
back to their park's photo, because one photo of two duelling coasters can only
carry one `ride`. Also fixed a line in `setup.md` that still named the two
generators this PR deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
`generate-hero-images.mjs` and `generate-attraction-images.mjs` were still on
disk: unreferenced by package.json, by prebuild and by any module, and writing to
`lib/hero-images.ts` / `lib/attraction-images.ts`, which this PR deleted. Running
either would have produced files nothing imports.

Found while checking whether anything was left over, and the docs I wrote one
commit ago already claimed they were gone — which was true of everything except
the files themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
The editor was the media database's second write path and the only one that
ignored its contract: `save/route.ts` committed the bytes to
`public/media/<post>/…` and nothing else. The file was in the tree but
undescribed — no alt, no rights, no tags. The generator does include it in the
manifest, so it was reachable, but an image nobody ever wrote a row for is an
image nobody goes looking for.

Rather than removing the upload (which would cost the paste-a-screenshot-while-
writing flow) or embedding the media walkthrough (which would ask park, ride and
focal point about an article screenshot — three questions with no sensible
answer), the drop now writes a sidecar with what the editor genuinely knows.

That turns out to be the most valuable field: `![alt | caption](src)` is read out
of EVERY filled locale, so a picture used in six translations arrives with six
languages — more than the media uploader collects, which asks for German and
leaves the rest for later. Plus `photo` or `diagram`, and `license: unknown`.

Park, ride, focal point and author stay unset deliberately. A screenshot shows no
ride, nothing has looked at the picture yet, and the post's author wrote the post,
not necessarily the photo — inventing one to clear the warning is the single thing
`public/media/README.md` forbids outright. Those images land in the admin's
"No park" / "No focal point" / "Rights unknown" lists, which is what the backlog
filters are for. An existing sidecar is never overwritten: it was either
hand-authored or written by the media admin, and both know more.

Extracted to `lib/admin/blog-image-sidecar.ts` so it can be tested, because the
failure mode is silence — a regex that stops matching means an empty sidecar, the
image committed anyway, and the alt text quietly lost. `pnpm
test:blog-image-sidecar` covers 15 cases including path escaping (an unescaped `.`
in a filename matches any character and would attach the wrong image's alt text).

Nothing to clean up: `audit:media` reported `sidecar: complete`, so no editor
upload has happened since the migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
Moving is the only admin edit that changes an image URL. Retagging does
not: park, ride, roles and text are not part of the path, and the content
version is sha1(bytes + focus), so assigning a park leaves ?v= alone.
A move renames the file, and a post still naming the old path renders a
404 with a green build — which is what happened when the four duplicate
images were merged and a gallery kept pointing at the file that went away.

That left an unpleasant trade: leave the tree disorganised, or tidy it up
and break articles. The commit route now rewrites the references in the
same pull request, so the tree can be filed by park and moving stops
costing anything.

- lib/admin/media-references.ts finds the posts via the build-time bodies
  manifest (no API calls) and rewrites source and crop paths, taking the
  extension from the destination because a move can be a replace too.
- The pattern is anchored so taron-queue is not swept along with taron.
- generate-media-manifest.mjs fails the prebuild on a /media/ path in an
  article that no longer exists in the database; README files are skipped
  because the authoring guide quotes example paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn
@PArns
PArns marked this pull request as ready for review August 5, 2026 11:14
@PArns
PArns merged commit 98e9d90 into main Aug 5, 2026
3 checks passed
@PArns
PArns deleted the claude/unified-image-database-ee5q0m branch August 5, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants