From c6cebc5000c6dbad3eecfcb03bfecdfcb70e5613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 5 Sep 2026 15:19:04 +0100 Subject: [PATCH] zoom and pan the lead photograph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Shape control it got last commit did nothing, and the reason is the trap that file already warns about: `contentFor` is a per-kind allowlist and everything it does not name is dropped. `subject-cover` was not in it, so the panel wrote a ratio, got a 200, and the API stored `{}`. Its own comment says that is "exactly what silently threw `ratio` away when the admin first sent one". Now it holds a ratio and a crop, and the block gets the same framing surface the About Us hero has. Two things it does NOT get, both deliberate: No Change and no Remove — `fixed` on MediaField. The picture is the subject's first carousel item and this block cannot store a different one. Framing is its decision; which photograph is not. No local knowledge of which picture that is. The site answers `/pages/:id/subject-lead`, because which template draws which section, which row is the example and which of its pictures leads are four facts that live in templates.ts — a second copy here is how the two drift when a fifth template arrives. And it says out loud that the crop applies to every row the template draws. It is framed against one photograph and used on all of them, which is the one surprising thing about cropping a template rather than a page. Co-Authored-By: Claude Opus 5 (1M context) --- web/MediaField.tsx | 57 +++++++++++++++++---------- web/sections/BlockMedia.tsx | 4 ++ web/sections/PageBuilderPanel.tsx | 64 +++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/web/MediaField.tsx b/web/MediaField.tsx index 5739ee3..acce999 100644 --- a/web/MediaField.tsx +++ b/web/MediaField.tsx @@ -101,6 +101,7 @@ export const MediaField = ({ aspect, kind = 'photo', label, + fixed = false, }: { /** A path under `originals/`, or null. */ value: string | null; @@ -114,6 +115,14 @@ export const MediaField = ({ kind?: 'photo' | 'video'; /** What the picker's title says, so it names the thing rather than saying "media". */ label?: string; + /** + * The picture is not this field's to change — only to frame. + * + * For a slot whose image comes from somewhere else: the subject's lead photograph is the first item + * of its carousel, so Change and Remove here would offer to do something this block cannot store. + * The zoom and pan stay, because framing IS the block's own decision. + */ + fixed?: boolean; }) => { const [picking, setPicking] = useState(false); const [over, setOver] = useState(false); @@ -382,27 +391,33 @@ export const MediaField = ({ )} - - + {/* Framing is this block's decision; WHICH picture is not, when the picture comes from + somewhere else. See `fixed`. */} + {!fixed && ( + <> + + + + )} )} diff --git a/web/sections/BlockMedia.tsx b/web/sections/BlockMedia.tsx index ccd1318..1f9b52f 100644 --- a/web/sections/BlockMedia.tsx +++ b/web/sections/BlockMedia.tsx @@ -27,6 +27,7 @@ export const BlockMedia = ({ aspect, onUrl, onCrop, + fixed = false, }: { url: string | null; crop: string | null; @@ -34,6 +35,8 @@ export const BlockMedia = ({ aspect: string; onUrl: (url: string | null) => void; onCrop: (crop: string | null) => void; + /** The picture is fixed and only its framing is editable — see MediaField. */ + fixed?: boolean; }) => { const [draft, setDraft] = useState(crop); @@ -49,6 +52,7 @@ export const BlockMedia = ({ onCropChange={setDraft} aspect={aspect} label="Choose a photograph" + fixed={fixed} /> ); }; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 57b2178..945b1ee 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -202,6 +202,18 @@ export const PageBuilderPanel = () => { */ const [rendered, setRendered] = useState(0); + /* + The lead photograph the TEMPLATE is previewing, so its cover block can be framed. + + Asked of the site rather than worked out here: which template draws which section, which row is + the example, and which of its pictures leads are four facts that live in `templates.ts`, and a + second copy in this panel is how the two drift when a fifth template arrives. + + Null on an ordinary page, on a template nothing routes, and on a section with no rows — all + ordinary states, and all of them mean the same thing here: nothing to frame. + */ + const [subjectLead, setSubjectLead] = useState(null); + /* The builder's chrome, painted into the rendered page. @@ -400,6 +412,22 @@ export const PageBuilderPanel = () => { const page = pageFromParam(pages, slug); + useEffect(() => { + if (!page?.is_template) { + setSubjectLead(null); + return; + } + let alive = true; + void clientRef.current + .get<{ data: { path: string | null } }>(`/fofos-admin/pages/${page.id}/subject-lead`) + .then((body) => alive && setSubjectLead(body.data.path)) + // Left null, which the block reads as "nothing to frame" — the same as a template with no rows. + .catch(() => alive && setSubjectLead(null)); + return () => { + alive = false; + }; + }, [page?.id, page?.is_template]); + /* The page's sections, which the canvas needs to name and outline what it finds in the document. @@ -853,6 +881,42 @@ export const PageBuilderPanel = () => { ))} + + {/* + Zoom and pan, once there is a shape to frame INTO. + + No shape means the picture draws at its own proportions and there is no rectangle to + choose — the same rule the media block follows, where the cropper appears only after a + shape is picked. + + `fixed`, so there is no Change or Remove: the picture is the subject's first carousel + item and this block cannot store a different one. Framing is its decision; which + photograph is not. + */} + {shapeOf({ content: { url: 'x.png', ratio: block.content?.ratio } }) && subjectLead && ( + <> + {}} + onCrop={(crop) => + void handlersRef.current.setContent(block, { ...block.content, crop: crop || undefined }) + } + fixed + /> + {/* + Said plainly, because it is the one surprising thing about framing a template. + + A template has no subject of its own; this is the row it previews against. The + rectangle chosen here is used for EVERY row the template draws, so it is framed + against one photograph and applied to all of them. + */} +

+ Framed against the preview. This crop applies to every one of them. +

+ + )} )}