a media slot can be framed by dragging and zooming

A photograph is rarely the shape of the slot it goes in, so something decides which part survives. It
was a centred crop and no way to change it.

Dragging the picture slides the window; the wheel zooms, anchored to the pointer so the thing under the
cursor stays under it. Both write the site's own format — `x,y,w,h` in percentages of the ORIGINAL —
which is resolution-independent and survives the file being re-encoded.

Zooming in is the window getting smaller: w and h shrink together, keeping the slot's shape. Fit is the
floor, because you cannot show more of a photograph than there is, and at exactly fit the crop is
cleared rather than written as the centred value — a null crop and a crop that happens to be centred
should not be two states.

── The preview is not object-cover ──

It draws the rectangle: the picture scaled to `100/w` of the box and offset so the chosen corner lands
at the origin. object-cover shows the largest window of the slot's shape that fits and nothing else —
it can be panned and it cannot be zoomed, so a preview built on it would silently ignore half of what
this control produces.

── Also here ──

buildSite now hands the site an av-club URL and user. That is the first piece of publishing images
cropped rather than cropping them in the browser with CSS: the plugin knows the sidecar registry, the
site knows a URL. Inert until the site uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 23:57:33 +01:00
co-authored by Claude Opus 5
parent 9d3cd7702f
commit fe51c70f70
3 changed files with 234 additions and 3 deletions
+17 -1
View File
@@ -55,6 +55,7 @@ export const AboutPanel = () => {
const [about, setAbout] = useState<About | null>(null);
const [headerUrl, setHeaderUrl] = useState('');
const [phrase, setPhrase] = useState('');
const [headerCrop, setHeaderCrop] = useState<string | null>(null);
const [aboutText, setAboutText] = useState('');
const [productVideo, setProductVideo] = useState('');
const [productText, setProductText] = useState('');
@@ -67,6 +68,7 @@ export const AboutPanel = () => {
const apply = useCallback((data: About) => {
setAbout(data);
setHeaderUrl(data.header.url ?? '');
setHeaderCrop(data.header.crop);
setPhrase(data.header.phrase ?? '');
setAboutText(data.about_text ?? '');
setProductVideo(data.product_video_url ?? '');
@@ -92,6 +94,7 @@ export const AboutPanel = () => {
/** Every field this page owns, as the API spells them. One place, so save and dirty cannot disagree. */
const edited = {
header_url: orNull(headerUrl),
header_crop: headerCrop,
header_phrase: orNull(phrase),
about_text: orNull(aboutText),
product_video_url: orNull(productVideo),
@@ -101,6 +104,7 @@ export const AboutPanel = () => {
const stored = about && {
header_url: about.header.url,
header_crop: about.header.crop,
header_phrase: about.header.phrase,
about_text: about.about_text,
product_video_url: about.product_video_url,
@@ -153,7 +157,19 @@ export const AboutPanel = () => {
{/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two
have to agree or the admin shows a shape the site does not have, which is worse than showing
no shape at all: it looks decided. */}
<MediaField value={headerUrl || null} onChange={(path) => setHeaderUrl(path ?? '')} aspect="21/9" label="header photograph" />
<MediaField
value={headerUrl || null}
onChange={(path) => {
setHeaderUrl(path ?? '');
// A framing belongs to the picture it was made for. Keeping it across a change would apply
// one photograph's decision to another one's composition.
setHeaderCrop(null);
}}
crop={headerCrop}
onCropChange={setHeaderCrop}
aspect="21/9"
label="header photograph"
/>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Phrase</label>