From 5968cb5d8386c57564e4b84869e1d63fb2969b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 5 Sep 2026 15:41:37 +0100 Subject: [PATCH] each row picks its own main image, and frames it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shape is one decision for the whole section and stays on the template — every collab is a 21:9 band or none of them is. Which part of THIS photograph survives that shape is a decision about this photograph, and there is one per row. A single rectangle chosen against one lead and applied to all of them is how a portrait ends up cropped through somebody's head, which is what the block-level crop did and why it is gone. So a row's settings now carry Main image: a picture and a cropping frame in the proportions the page asked for. Empty means the first item of its carousel, which is the ordinary state and is not stored — reorder the carousel and the lead follows. Choosing one is the deliberate act of saying "not that one", and it may be a picture the carousel does not contain at all. `cover_shape` rides along with each row so this panel can draw the right frame without knowing how a template is assembled. SHAPES moved out of PageBuilderPanel because two screens offer them now, and two lists is two places to add the next ratio. Co-Authored-By: Claude Opus 5 (1M context) --- web/sections/PageBuilderPanel.tsx | 19 +--------- web/sections/SubjectFields.tsx | 62 +++++++++++++++++++++++++++++++ web/sections/SubjectPanel.tsx | 13 ++++++- web/sections/shapes.ts | 18 +++++++++ web/sections/subjects.ts | 12 ++++++ 5 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 web/sections/shapes.ts diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 945b1ee..7f8bb3c 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -55,6 +55,7 @@ const PLACES_ONLY: string[] = [ 'subject-specs', 'subject-carousel', ]; +import { SHAPES } from './shapes'; import { AddSectionDialog, type SectionLayout } from './AddSectionDialog'; import { FreeTextEditor } from './FreeTextEditor'; import { SpacingEditor, type Padding } from './SpacingEditor'; @@ -67,23 +68,7 @@ import { BlockMedia } from './BlockMedia'; import { PageStructure } from './PageStructure'; import { BandBlocks } from './BandBlocks'; -/** - * 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; diff --git a/web/sections/SubjectFields.tsx b/web/sections/SubjectFields.tsx index 1b68809..83b1be1 100644 --- a/web/sections/SubjectFields.tsx +++ b/web/sections/SubjectFields.tsx @@ -4,6 +4,8 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { RichText } from '../RichText'; import { useAutosave } from '../useAutosave'; +import { MediaField } from '../MediaField'; +import { SHAPES } from './shapes'; import type { SubjectRecord, SubjectSection } from './subjects'; /** @@ -27,10 +29,13 @@ import type { SubjectRecord, SubjectSection } from './subjects'; export const SubjectFields = ({ section, record, + firstPhoto, onSaved, }: { section: SubjectSection; record: T; + /** The first picture in the row's carousel — what the cover falls back to. */ + firstPhoto?: string | null; onSaved: () => void; }) => { const client = useClient(); @@ -53,6 +58,28 @@ export const SubjectFields = ({ const set = (key: string) => (next: string) => setDraft((was) => ({ ...was, [key]: next })); + /* + The cover writes STRAIGHT through, not into the draft. + + The draft exists so a person typing does not send a request per keystroke — see useAutosave above. + Choosing a picture and dragging a crop are single acts with a single outcome, so they save when + they happen; putting them in the draft would make a crop wait on a debounce and, worse, be + discarded by the `setDraft({})` that follows every save. + */ + const save = (values: Record) => { + void client.patch(`${section.endpoint}/${record.id}`, values).then(onSaved); + }; + + /* Null means the carousel's first item, which the panel already knows — see SubjectPanel. */ + const cover = record.cover_url ?? firstPhoto ?? null; + + /* Tailwind's spelling, from the template's ratio. `1/1` when there is no shape, because a cropping + frame has to have SOME proportions and a square says least about the picture. */ + const aspect = + record.cover_shape === null + ? '1/1' + : (SHAPES.find((one) => one.ratio && Math.abs(one.ratio - record.cover_shape!) < 0.01)?.value ?? '1/1'); + return (
@@ -60,6 +87,41 @@ export const SubjectFields = ({ set('title')(event.target.value)} />
+ {/* + The picture that represents this row, and how it is framed. + + ── Why the framing is HERE and the shape is not ── + + The shape is one decision for the whole section and lives on the template — every collab is a + 21:9 band or none of them is. Which part of THIS photograph survives that shape is a decision + about this photograph, and there is one per row. A single rectangle chosen against one lead and + applied to all of them is how a portrait ends up cropped through somebody's head. + + ── Why it can be empty ── + + Null means "the first item of the carousel", which is the ordinary state and is not stored: + reorder the carousel and the lead follows, which is what dragging it there is for. Choosing one + here is the deliberate act of saying "not that one" — and it may be a picture the carousel does + not contain at all. + */} +
+ + save({ cover_url: path, ...(path ? {} : { cover_crop: null }) })} + crop={record.cover_crop} + onCropChange={(crop) => save({ cover_crop: crop })} + aspect={aspect} + label="Choose the main image" + /> +

+ {record.cover_url + ? 'Chosen for this one. Remove it to go back to the first picture in its carousel.' + : 'From the first picture in its carousel. Choose one to override that.'} + {record.cover_shape === null && ' No shape is set on the page, so it draws at its own.'} +

+
+
set('slug')(event.target.value)} /> diff --git a/web/sections/SubjectPanel.tsx b/web/sections/SubjectPanel.tsx index db36beb..65bdee2 100644 --- a/web/sections/SubjectPanel.tsx +++ b/web/sections/SubjectPanel.tsx @@ -116,9 +116,18 @@ export const SubjectPanel = ({ section }: { section: Su useEffect(() => { setEditorRef.current( - selected ? void load()} /> : null, + selected ? ( + void load()} + /> + ) : null, ); - }, [selected, load, section]); + }, [selected, load, section, photos]); useEffect(() => () => setEditorRef.current(null), []); diff --git a/web/sections/shapes.ts b/web/sections/shapes.ts new file mode 100644 index 0000000..6601f7c --- /dev/null +++ b/web/sections/shapes.ts @@ -0,0 +1,18 @@ +/** + * The shapes a slot can be framed into, in one place. + * + * Shared because two screens now offer them: the page builder sets the shape on a template's lead + * block, and a row's settings draw a cropping frame of those proportions. Two lists would be two + * places to add the next ratio, and the day they disagree a crop is framed against a shape the page + * does not use. + */ +export 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 }, +]; diff --git a/web/sections/subjects.ts b/web/sections/subjects.ts index 3b44e7f..11f0948 100644 --- a/web/sections/subjects.ts +++ b/web/sections/subjects.ts @@ -32,6 +32,18 @@ export type SubjectRecord = { description: string | null; sort_order: number; photo_count: number; + /** The row's own lead, or null meaning "the first item of its carousel". */ + cover_url: string | null; + /** How that picture is framed, `x,y,w,h` in percentages. Null is centred. */ + cover_crop: string | null; + /** + * The shape the template frames a lead into, or null for the picture's own. + * + * Read off the page, not the row — one decision for the whole section — and sent with the row so + * this panel can draw a cropping frame of the right proportions without knowing how a template is + * put together. + */ + cover_shape: number | null; }; export type Collection = SubjectRecord & {