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. +

+ + )} )}