From 1ec361d0065e1bdfdd0ad115154154167b7aad01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 19:50:18 +0100 Subject: [PATCH] a block's spacing sits with its title; page settings need the page selected Spacing had a link in the chrome painted onto the page and lost its door with the rest of it. It belongs beside the heading: both are properties of the selected block, and one of the two is not worth a place of its own to go to. The page's name and address now show only when the page row itself is selected. With a band or a block open they sat above an editor about something else and read as belonging to it. Co-Authored-By: Claude Opus 5 (1M context) --- web/EditorPanel.tsx | 11 +++- web/sections/BlockSettings.tsx | 93 +++++++++++++++++++++++++++++-- web/sections/PageBuilderPanel.tsx | 1 + 3 files changed, 97 insertions(+), 8 deletions(-) diff --git a/web/EditorPanel.tsx b/web/EditorPanel.tsx index dbb7437..2186794 100644 --- a/web/EditorPanel.tsx +++ b/web/EditorPanel.tsx @@ -40,9 +40,16 @@ export const EditorPanel = () => { Above the device toggle, because the toggle scopes what is under it to one width and none of these three have widths. */ - const { section, item } = useNav(); + const { section, item, part } = useNav(); const { pages } = usePages(); - const page = section === 'pages' ? pageFromParam(pages, item) : null; + /* + Only when the PAGE row itself is selected. + + A band or a block selected means the editor below is about that, and the page's own name and + address sitting above it read as belonging to whatever is open. They are reached by pressing the + page again, which is what the nav already implies. + */ + const page = section === 'pages' && !part ? pageFromParam(pages, item) : null; return (
diff --git a/web/sections/BlockSettings.tsx b/web/sections/BlockSettings.tsx index eb485b0..aa327d5 100644 --- a/web/sections/BlockSettings.tsx +++ b/web/sections/BlockSettings.tsx @@ -4,6 +4,7 @@ import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { useAutosave } from '../useAutosave'; import { useFlash } from '../useFlash'; +import { useDevice } from '../DeviceToggle'; /** * A block's own heading. Optional, and above whatever editor the block has. @@ -21,24 +22,54 @@ export const BlockSettings = ({ pageId, sectionId, block, + steps, onSaved, }: { pageId: number; sectionId: number; - block: { id: number; title?: string | null }; + block: { id: number; title?: string | null; padding?: Record }; + /** The padding scale, sent by the API so this file holds no copy of it. */ + steps: number[]; onSaved: () => void; }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; - const [draft, setDraft] = useState(block.title ?? ''); + const [device] = useDevice(); + const breakpoint = ({ desktop: 'lg', tablet: 'md', mobile: 'base' } as const)[device]; + + const [draft, setDraft] = useState({ title: block.title ?? '', padding: block.padding ?? {} }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, flashSaved] = useFlash(); // Keyed on the id, so re-reading the same block after a save does not throw away what is being typed. - useEffect(() => setDraft(block.title ?? ''), [block.id]); + useEffect(() => setDraft({ title: block.title ?? '', padding: block.padding ?? {} }), [block.id]); + + /** What an axis falls back to at this width: the nearest smaller one that has a value. */ + const inherited = (axis: 'x' | 'y'): number | null => { + const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const; + for (const smaller of below[breakpoint]) { + const step = draft.padding[smaller]?.[axis]; + if (typeof step === 'number') return step; + } + return null; + }; + + const setPadding = (axis: 'x' | 'y', value: string) => { + const at = { ...(draft.padding[breakpoint] ?? {}) }; + if (value === '') delete at[axis]; + else at[axis] = Number(value); + + const next = { ...draft.padding }; + // An empty breakpoint is removed rather than kept as `{}`, so "inherit everything" stays an + // absence instead of a thing that has to be told apart from one later. + if (Object.keys(at).length) next[breakpoint] = at; + else delete next[breakpoint]; + + setDraft((was) => ({ ...was, padding: next })); + }; const save = async () => { setBusy(true); @@ -47,7 +78,7 @@ export const BlockSettings = ({ // Empty is sent as an empty string, which the API reads as "take the heading off". await clientRef.current.patch( `/fofos-admin/pages/${pageId}/sections/${sectionId}/components/${block.id}`, - { title: draft }, + { title: draft.title, padding: draft.padding }, ); flashSaved(); onSaved(); @@ -58,12 +89,62 @@ export const BlockSettings = ({ } }; - useAutosave(draft !== (block.title ?? '') ? draft : null, busy, save); + const stored = JSON.stringify({ title: block.title ?? '', padding: block.padding ?? {} }); + const pending = JSON.stringify(draft); + + useAutosave(pending !== stored ? pending : null, busy, save); return (
- setDraft(event.currentTarget.value)} /> + setDraft((was) => ({ ...was, title: event.currentTarget.value }))} + /> + + {/* + The block's own spacing, at the width the toggle above is showing. + + It had a Spacing link in the chrome painted onto the page, which went with the rest of it. It + belongs here: it is a property of the selected block, like the heading, and one of the two is + not worth a separate place to go. + + Unset inherits the smaller width, and 0 is the other instruction — none HERE even though the + phone has some. So they are separate options. + */} +
+ {( + [ + ['x', 'Horizontal padding'], + ['y', 'Vertical padding'], + ] as const + ).map(([axis, label]) => { + const own = draft.padding[breakpoint]?.[axis]; + const from = inherited(axis); + + return ( +
+ + +
+ ); + })} +
+ {saved &&

Saved

} {error &&

{error}

}
diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index a4aa75c..465259c 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -602,6 +602,7 @@ export const PageBuilderPanel = () => { pageId={page.id} sectionId={owner.id} block={block} + steps={steps} onSaved={() => setRendered((n) => n + 1)} /> {editing && (