diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 5db8a44..950df3c 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -38,6 +38,34 @@ import { BandSettings } from './BandSettings'; import { BlockSettings } from './BlockSettings'; import { GalleryItems } from './GalleryItems'; import { PartnersEditor } from './PartnersEditor'; +import { MediaField } from '../MediaField'; + +/** + * The shapes a photograph block may take. + * + * One list, because three things need it and must agree: the select's options, the number stored as + * `content.ratio`, and the Tailwind aspect `MediaField` frames into. Two of those are strings and one + * is a float, which is exactly the drift a single table prevents. + */ +const SHAPES = [ + { value: '', label: "The picture's own shape", ratio: null as number | null }, + { value: '21/9', label: '21:9 — a letterbox band', ratio: 21 / 9 }, + { value: '16/9', label: '16:9', ratio: 16 / 9 }, + { value: '3/2', label: '3:2', ratio: 3 / 2 }, + { value: '4/3', label: '4:3', ratio: 4 / 3 }, + { value: '1/1', label: '1:1 — square', ratio: 1 }, + { value: '3/4', label: '3:4 — upright', ratio: 3 / 4 }, + { value: '2/3', label: '2:3 — upright', ratio: 2 / 3 }, +]; + +/** Stored as a float, matched with a tolerance: 21/9 does not survive a round trip exactly. */ +const closeTo = (a: number, b: unknown): boolean => typeof b === 'number' && Math.abs(a - b) < 0.01; + +/** The Tailwind aspect for a block's stored ratio, or null when it keeps its own shape. */ +const shapeOf = (block: { content?: { url?: string; ratio?: number } }): string | null => { + if (!block.content?.url || /\.(mp4|mov|webm|m4v)$/i.test(block.content.url)) return null; + return SHAPES.find((one) => one.ratio && closeTo(one.ratio, block.content?.ratio))?.value ?? null; +}; import { useEditorSlot } from '../useEditorSlot'; import { AddComponentDialog, type ComponentKind } from './AddComponentDialog'; import { MediaPicker } from '../MediaPicker'; @@ -737,79 +765,87 @@ export const PageBuilderPanel = () => { )} {(block.kind === 'media' || block.kind === 'video') && ( -
-
- - - {block.content?.url ? block.content.url.split('/').pop() : 'Nothing chosen'} - -
- +
{/* - The shape of the slot, for a PHOTOGRAPH. + The shape of the slot, and the framing inside it. - It is how you make a band shorter than the picture in it: the ratio becomes a slot - shape, and the site asks av-club for a rendition cut to that — so the bytes served - match the space rather than being scaled down by the browser. + The ratio becomes a slot shape and the site serves a rendition cut to it, so a band + shorter than its photograph costs FEWER bytes rather than more. - Not offered for a video. There is nothing to crop and a ratio would letterbox it. + With a shape chosen, `MediaField` replaces the plain thumbnail: it draws the picture in + that shape and lets somebody drag and zoom to say which part shows. Without one there + is nothing to frame into — the whole picture is used — so framing would be a control + that does nothing. - `content` is replaced wholesale by the API, so this sends what is already there - alongside the new value — a PATCH with `{ ratio }` alone would drop the url. + `content` is replaced wholesale by the API, so every write sends what is already there + alongside the new value. */} - {block.content?.url && !/\.(mp4|mov|webm|m4v)$/i.test(block.content.url) && ( + {!/\.(mp4|mov|webm|m4v)$/i.test(block.content?.url ?? '') && (
)} - {/* - What is actually in the block, at the width of the column. - - A button that says "Choose media…" and nothing else asks somebody to open a dialog to - find out what they already chose. Full width and its own aspect, because a strip of a - photograph tells you it is a photograph and not which one. - - A video shows its POSTER rather than playing: the question here is which file this is, - and a frame answers it. - */} - {block.content?.url && ( - void handlersRef.current.setContent(block, { ...block.content, url: url ?? '' })} + crop={block.content?.crop ?? null} + onCropChange={(crop) => + void handlersRef.current.setContent(block, { ...block.content, crop: crop ?? undefined }) } - alt="" - className="w-full rounded border border-border" + aspect={shapeOf(block)!} + label="Choose a photograph" /> + ) : ( + <> +
+ + + {block.content?.url ? block.content.url.split('/').pop() : 'Nothing chosen'} + +
+ + {/* A video shows its POSTER rather than playing: the question is which file this is. */} + {block.content?.url && ( + + )} + )}
)}