feat(media): one filesystem-backed image database for parks, rides and the blog - #269
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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
…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
… 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: `` 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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}.jsonsidecar per image.Nothing queries by folder. A Halloween photo of Troy sits in the
toverland-halloweencollection and still answersgetRideImages('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.public/images/parks/{park}/{ride}.jpgpublic/media/{collection}/{name}.jpgpublic/blog/images/{folder}/+captions.json× 6 localeshero-images.ts,hero-images-meta.ts,attraction-images.tslib/media/manifest*.tsfs.existsSyncprobing per requestFocal points
object-fit: covercrops 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 CSSobject-positionon 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/mediais 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 (grun→grü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/hero— the only client-safe entry, reading a 21 KB slice, because the hero rotation runs in Client Components.Admin —
/admin/mediaSearchable 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:
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 inpnpm 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,rolesand text are not part of the path, and the version issha1(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.jpegwas dead. The old resolver matched a ride by filename; the API slug ismaximus-blitz-bahn. That photo never rendered.disneyland-park,universal-islands-of-adventure). The Disneyland background turned out to be Paris, not Anaheim.The data pass
efteling/symbolica.jpg,movie-park-germany/iron-claw.jpg,phantasialand/winjas-fear.jpgandwalibi-holland/yoy-chill.jpgsurvive; the six posts that named the others were rewritten.CLAUDE.mdandpublic/media/README.md.Verification
pnpm release:checkgreen (lint, format, translations, client messages),pnpm buildgreen.test:media49/49,test:media-session12/12,test:media-references9/9,test:blog-image-sidecar15/15.pnpm check:media-urlswalks 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/mediafiltering, and zero legacy image paths in any rendered HTML.Caption migration checked entry-by-entry: 31 images / 372 locale fields before and after.
Notes
git mv, so history follows them.Docs:
docs/features/media-database.mdand the authoring contract atpublic/media/README.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01UaVtda1khmn5Ctu7MFJGpn