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
+20
View File
@@ -2,6 +2,8 @@ import { stat, readdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { db } from 'officerdb/db';
import { fofosAdminSettings } from '../db/schema';
import { sidecarPort } from '@@/sidecar-registry';
import { getOwnerUser } from 'officerdb';
import { readDatabases } from './databases';
/**
@@ -139,6 +141,12 @@ export async function buildSite(secrets: {
if (!row?.rootPath) throw new Error('No root location is set.');
const websiteDir = join(row.rootPath, 'website');
// Absent when av-club is not installed. The build is told, rather than left to fail on a fetch to a
// port that is not there — and it can then say so plainly instead of timing out.
const avClubPort = sidecarPort('av-club');
// av-club resolves every path against a user's own media directory, so the build has to say who it
// is working for. The owner, because that is whose root location this plugin administers.
const owner = await getOwnerUser();
const databases = await readDatabases();
if (!databases.production.name || !databases.production.exists) {
throw new Error('The production database does not exist yet.');
@@ -160,6 +168,18 @@ export async function buildSite(secrets: {
FOFOS_API_TOKEN: secrets.apiToken,
ADMIN_SESSION_SECRET: secrets.sessionSecret,
FOFOS_MEDIA_DIR: join(row.rootPath, 'media'),
/*
How the build reaches av-club.
The build crops each placement to the exact shape and size its slot renders at, rather than
shipping a whole photograph and asking CSS to hide most of it. That needs an encoder, and the
rule is that every encoder is av-club — so the plugin, which knows the sidecar registry, hands
the site a URL and the site knows nothing else about the platform.
`x-officer-user` is av-club's whole authentication, and the build has to send it: the sidecar
resolves paths against that user's own media directory.
*/
...(avClubPort && owner?.email ? { AV_CLUB_URL: `http://127.0.0.1:${avClubPort}`, AV_CLUB_USER: owner?.email ?? '' } : {}),
// Only used to build absolute URLs in the output — og:image, canonical links. The real domain
// belongs in the checkout's own env, where it is a deployment fact rather than one this invented.
PUBLIC_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',