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) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 19:50:18 +01:00
co-authored by Claude Opus 5
parent 361684b8c5
commit 1ec361d006
3 changed files with 97 additions and 8 deletions
+9 -2
View File
@@ -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 Above the device toggle, because the toggle scopes what is under it to one width and none of these
three have widths. three have widths.
*/ */
const { section, item } = useNav(); const { section, item, part } = useNav();
const { pages } = usePages(); 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 ( return (
<div className="flex size-full flex-col overflow-hidden"> <div className="flex size-full flex-col overflow-hidden">
+87 -6
View File
@@ -4,6 +4,7 @@ import { useClient } from 'hooks/useClient';
import { readableError } from '../errors'; import { readableError } from '../errors';
import { useAutosave } from '../useAutosave'; import { useAutosave } from '../useAutosave';
import { useFlash } from '../useFlash'; import { useFlash } from '../useFlash';
import { useDevice } from '../DeviceToggle';
/** /**
* A block's own heading. Optional, and above whatever editor the block has. * A block's own heading. Optional, and above whatever editor the block has.
@@ -21,24 +22,54 @@ export const BlockSettings = ({
pageId, pageId,
sectionId, sectionId,
block, block,
steps,
onSaved, onSaved,
}: { }: {
pageId: number; pageId: number;
sectionId: number; sectionId: number;
block: { id: number; title?: string | null }; block: { id: number; title?: string | null; padding?: Record<string, { x?: number; y?: number }> };
/** The padding scale, sent by the API so this file holds no copy of it. */
steps: number[];
onSaved: () => void; onSaved: () => void;
}) => { }) => {
const client = useClient(); const client = useClient();
const clientRef = useRef(client); const clientRef = useRef(client);
clientRef.current = 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 [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash(); 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. // 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 () => { const save = async () => {
setBusy(true); 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". // Empty is sent as an empty string, which the API reads as "take the heading off".
await clientRef.current.patch( await clientRef.current.patch(
`/fofos-admin/pages/${pageId}/sections/${sectionId}/components/${block.id}`, `/fofos-admin/pages/${pageId}/sections/${sectionId}/components/${block.id}`,
{ title: draft }, { title: draft.title, padding: draft.padding },
); );
flashSaved(); flashSaved();
onSaved(); 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 ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs">Title</label> <label className="text-xs">Title</label>
<Input value={draft} placeholder="No heading" onChange={(event) => setDraft(event.currentTarget.value)} /> <Input
value={draft.title}
placeholder="No heading"
onChange={(event) => 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.
*/}
<div className="mt-3 grid grid-cols-2 gap-3">
{(
[
['x', 'Horizontal padding'],
['y', 'Vertical padding'],
] as const
).map(([axis, label]) => {
const own = draft.padding[breakpoint]?.[axis];
const from = inherited(axis);
return (
<div key={axis} className="flex flex-col gap-1">
<label className="text-xs">{label}</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => setPadding(axis, event.currentTarget.value)}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
<option value="">
{breakpoint === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
</div>
);
})}
</div>
{saved && <p className="text-xs text-success">Saved</p>} {saved && <p className="text-xs text-success">Saved</p>}
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-destructive">{error}</p>}
</div> </div>
+1
View File
@@ -602,6 +602,7 @@ export const PageBuilderPanel = () => {
pageId={page.id} pageId={page.id}
sectionId={owner.id} sectionId={owner.id}
block={block} block={block}
steps={steps}
onSaved={() => setRendered((n) => n + 1)} onSaved={() => setRendered((n) => n + 1)}
/> />
{editing && ( {editing && (