diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 7f8bb3c..7d6ec37 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -68,8 +68,6 @@ import { BlockMedia } from './BlockMedia'; import { PageStructure } from './PageStructure'; import { BandBlocks } from './BandBlocks'; - - /** 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; @@ -187,18 +185,6 @@ 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. @@ -397,21 +383,6 @@ 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. @@ -776,6 +747,15 @@ export const PageBuilderPanel = () => { const owner = piece ? sections.find((one) => one.components.some((c) => String(c.id) === piece)) : null; const block = owner?.components.find((c) => String(c.id) === piece); + /* + The shape a `subject-cover` is framed into, as Tailwind spells it. + + `shapeOf` takes a whole block because a media block's shape depends on it having a picture at + all; a subject cover has no `url` of its own - the picture is the row's - so it was being asked + three times with a fabricated one. Asked once, from the only field that decides it. + */ + const coverShape = SHAPES.find((one) => closeTo(one.ratio ?? Number.NaN, block?.content?.ratio))?.value ?? null; + if (block && owner && page) { setEditorRef.current(
@@ -838,19 +818,25 @@ export const PageBuilderPanel = () => { )} {/* - The lead photograph gets Shape, and only Shape. + The lead photograph gets Shape, and ONLY Shape. - The same framing the media block has had since it existed — a template wanting a letterbox - lead had no way to ask for one. Not the rest of that block's controls: there is no file to - choose, because the picture is the carousel's first item, and no crop to set, because the - crop belongs to the carousel item and two crops for one placement is two answers able to - disagree. + The same framing the media block has had since it existed - a template wanting a letterbox + lead had no way to ask for one. Not the rest of that block's controls, and not a crop: + there is no file to choose, because the picture is the row's, and no rectangle to drag, + because a template draws EVERY row in its section and one rectangle framed against one + photograph goes through somebody's head on the next one. + + That is not a guess about what would happen - it is what did. This block briefly offered + zoom and pan, writing `content.crop`; the site stopped reading it when the crop moved to + the row, where there is one per picture, and the control stayed behind. It saved, it + persisted, and it changed nothing, under help text promising it applied to every row. The + per-row cropper is in each section's own settings - see SubjectFields. */} {block.kind === 'subject-cover' && (
- - {/* - 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. -

- - )} +

+ One shape for every row this template draws. Which part of each photograph survives it + is set on the row itself. +

)} diff --git a/web/sections/SubjectFields.tsx b/web/sections/SubjectFields.tsx index 8f2f8c6..51a6192 100644 --- a/web/sections/SubjectFields.tsx +++ b/web/sections/SubjectFields.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { useClient } from 'hooks/useClient'; +import { readableError } from '../errors'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { RichText } from '../RichText'; @@ -41,6 +42,20 @@ export const SubjectFields = ({ const client = useClient(); const [draft, setDraft] = useState>>({}); + /* + What went wrong, because otherwise nothing says. + + Every save here is silent by design - there is no button to report back to, which is the whole + point of useAutosave. That is fine while it works and indefensible when it does not: the two paths + below had no failure branch at all, so a rejected PATCH was an unhandled promise and a person + watching their own change on screen had no way to know it had not been written. + + It matters most for the cover, which does not go through the draft. Typing survives a failure - + the draft is only cleared on success, so the next keystroke tries again with a new value. A crop + that fails is simply gone, and the picture goes on showing the rectangle that was dragged. + */ + const [error, setError] = useState(null); + // Keyed on the ID, not the values: keyed on the values it would fight the typing, pulling every // keystroke back to the last saved one until the save landed. useEffect(() => setDraft({}), [record.id]); @@ -51,9 +66,16 @@ export const SubjectFields = ({ const dirty = Object.keys(draft).length > 0; useAutosave(dirty ? JSON.stringify(draft) : null, false, async () => { - await client.patch(`${section.endpoint}/${record.id}`, draft); - setDraft({}); - onSaved(); + try { + await client.patch(`${section.endpoint}/${record.id}`, draft); + setError(null); + // Only on success: what is still here is what has not been written, and clearing it would throw + // away the typing along with the news that it was lost. + setDraft({}); + onSaved(); + } catch (failure) { + setError(readableError(failure)); + } }); const set = (key: string) => (next: string) => setDraft((was) => ({ ...was, [key]: next })); @@ -67,7 +89,13 @@ export const SubjectFields = ({ discarded by the `setDraft({})` that follows every save. */ const save = (values: Record) => { - void client.patch(`${section.endpoint}/${record.id}`, values).then(onSaved); + void client + .patch(`${section.endpoint}/${record.id}`, values) + .then(() => { + setError(null); + onSaved(); + }) + .catch((failure: unknown) => setError(readableError(failure))); }; /* Null means the carousel's first item, which the panel already knows — see SubjectPanel. */ @@ -82,6 +110,12 @@ export const SubjectFields = ({ return (
+ {error && ( +

+ {error} +

+ )} +
set('title')(event.target.value)} />