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