Spacing, on a section and on a component

A Spacing handle on the section bar and in the component tools, opening an editor below the preview —
the same place the text editor goes, and for the same reason: a dialog covers the thing being spaced.

The device toggle picks the breakpoint. Two axes across three widths is six controls, five of them for a
width you are not looking at; this way you switch to Mobile, see the phone, and change the padding you
can watch changing. The site's GUTTER is the exception and shows all three at once, because it is one
axis and one value and it is a progression judged as a whole.

Inherit and 0 stay separate options, and the select names the value it is inheriting rather than leaving
it to be worked out.

Written IMMEDIATELY rather than drafted. A step is one choice from a short list and the effect is on
screen before the menu closes; a Save for it would be a second thing to remember on a panel whose other
verbs already act.

Spacing sits before Delete in the section bar, ordered by how often each is wanted rather than how they
read — the destructive one furthest from where the hand arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 06:29:38 +01:00
co-authored by Claude Opus 5
parent 5e351aa19f
commit 18c01de6f2
3 changed files with 249 additions and 24 deletions
+82 -4
View File
@@ -7,6 +7,7 @@ import { usePreviewable, useViewMode } from '../ViewMode';
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextEditor } from './FreeTextEditor';
import { SpacingEditor, type Padding } from './SpacingEditor';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
import { pageFromParam, usePages, type Page } from '../usePages';
@@ -40,7 +41,7 @@ export const PageBuilderPanel = () => {
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
usePreviewable();
const { mode } = useViewMode();
const { mode, device } = useViewMode();
const [error, setError] = useState<string | null>(null);
const [sections, setSections] = useState<CanvasSection[]>([]);
@@ -59,7 +60,17 @@ export const PageBuilderPanel = () => {
addComponent: (section: CanvasSection, column: number) => void;
editComponent: (component: CanvasComponent) => void;
removeComponent: (component: CanvasComponent) => void;
}>({ add: () => {}, remove: () => {}, addComponent: () => {}, editComponent: () => {}, removeComponent: () => {} });
spaceSection: (section: CanvasSection) => void;
spaceComponent: (component: CanvasComponent) => void;
}>({
add: () => {},
remove: () => {},
addComponent: () => {},
editComponent: () => {},
removeComponent: () => {},
spaceSection: () => {},
spaceComponent: () => {},
});
const [adding, setAdding] = useState(false);
const [creating, setCreating] = useState(false);
/** Where a component is about to be added, while the kind is being chosen. */
@@ -70,6 +81,12 @@ export const PageBuilderPanel = () => {
);
/** Choosing the file for a video — new when `id` is null, otherwise replacing an existing one. */
const [picking, setPicking] = useState<{ id: number | null; sectionId: number; column: number } | null>(null);
/** Whose spacing is being changed. A section or one component, never both. */
const [spacing, setSpacing] = useState<
{ kind: 'section' | 'component'; id: number; sectionId: number; label: string; padding: Padding } | null
>(null);
/** The scale the API publishes, so this file holds no copy of it. */
const [steps, setSteps] = useState<number[]>([]);
/*
Bumped after a write, and passed to SitePreview to remount the frame.
@@ -116,6 +133,8 @@ export const PageBuilderPanel = () => {
onAddComponent: (section, column) => handlersRef.current.addComponent(section, column),
onEditComponent: (component) => handlersRef.current.editComponent(component),
onDeleteComponent: (component) => handlersRef.current.removeComponent(component),
onSpaceSection: (section) => handlersRef.current.spaceSection(section),
onSpaceComponent: (component) => handlersRef.current.spaceComponent(component),
});
},
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
@@ -185,8 +204,11 @@ export const PageBuilderPanel = () => {
useEffect(() => {
if (!page) return;
void clientRef.current
.get<{ data: CanvasSection[] }>(`/fofos-admin/pages/${page.id}/sections`)
.then((response) => setSections(response.data))
.get<{ data: CanvasSection[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
.then((response) => {
setSections(response.data);
if (response.steps) setSteps(response.steps);
})
.catch((failure: unknown) => setError(readableError(failure)));
}, [page, rendered]);
@@ -307,12 +329,56 @@ export const PageBuilderPanel = () => {
}
};
/*
Spacing is written IMMEDIATELY, like adding and deleting.
There is no draft to lose: a step is one choice from a short list, and the effect is on screen before
the menu closes. A Save for it would mean a second thing to remember on a panel whose other verbs
already act.
*/
const saveSpacing = async (padding: Padding) => {
if (!page || !spacing) return;
setSpacing({ ...spacing, padding });
const base = `/fofos-admin/pages/${page.id}/sections/${spacing.sectionId}`;
const url = spacing.kind === 'section' ? base : `${base}/components/${spacing.id}`;
try {
await clientRef.current.patch(url, { padding });
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
const spaceSection = (section: CanvasSection) =>
setSpacing({
kind: 'section',
id: section.id,
sectionId: section.id,
label: section.name,
padding: (section.padding ?? {}) as Padding,
});
const spaceComponent = (component: CanvasComponent) => {
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
if (!owner) return;
const stored = owner.components.find((one) => one.id === component.id);
setSpacing({
kind: 'component',
id: component.id,
sectionId: owner.id,
label: component.kind === 'video' ? 'Video' : 'Text',
padding: (stored?.padding ?? {}) as Padding,
});
};
handlersRef.current = {
add: () => setAdding(true),
remove: (sectionId: number) => void deleteSection(sectionId),
addComponent,
editComponent,
removeComponent: (component) => void deleteComponent(component),
spaceSection,
spaceComponent,
};
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
@@ -353,6 +419,18 @@ export const PageBuilderPanel = () => {
title="Choose a video"
/>
{spacing && (
<SpacingEditor
label={spacing.label}
padding={spacing.padding}
device={device}
steps={steps}
busy={false}
onChange={(padding) => void saveSpacing(padding)}
onClose={() => setSpacing(null)}
/>
)}
{editing && (
<FreeTextEditor
html={editing.html}