From 18c01de6f2c293605249aed1d0b0e6ad70919145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 06:29:38 +0100 Subject: [PATCH] Spacing, on a section and on a component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/canvas.ts | 68 ++++++++++++----- web/sections/PageBuilderPanel.tsx | 86 ++++++++++++++++++++- web/sections/SpacingEditor.tsx | 119 ++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 24 deletions(-) create mode 100644 web/sections/SpacingEditor.tsx diff --git a/web/canvas.ts b/web/canvas.ts index a814fde..953bfce 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -49,13 +49,28 @@ const ACCENT = '#3b82f6'; * presses Edit, and it arrived with the section. Fetching it again on every Edit would be a request to * be told something already in hand. */ -export type CanvasComponent = { id: number; column: number; kind: string; content?: { html?: string } }; -export type CanvasSection = { id: number; name: string; layout: 'one' | 'two'; components: CanvasComponent[] }; +export type CanvasComponent = { + id: number; + column: number; + kind: string; + content?: { html?: string }; + padding?: Record; +}; +export type CanvasSection = { + id: number; + name: string; + layout: 'one' | 'two'; + padding?: Record; + components: CanvasComponent[]; +}; export type Canvas = { sections: CanvasSection[]; onAddSection: () => void; onDeleteSection: (section: CanvasSection) => void; + /** Somebody wants to change the space around a band or around one thing in it. */ + onSpaceSection: (section: CanvasSection) => void; + onSpaceComponent: (component: CanvasComponent) => void; /** A column wants something in it. */ onAddComponent: (section: CanvasSection, column: number) => void; /** Something already in a column was pressed. */ @@ -220,25 +235,37 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void { shape.textContent = section.layout === 'two' ? 'two columns' : 'one column'; style(shape, ['opacity:0.7']); - const remove = document.createElement('button'); - remove.type = 'button'; - remove.textContent = 'Delete'; - style(remove, [ - 'margin-left:auto', - 'cursor:pointer', - 'background:none', - 'border:0', - 'padding:0', - 'font:inherit', - 'color:inherit', - 'opacity:0.7', - 'text-decoration:underline', - ]); - const onRemove = () => canvas.onDeleteSection(section); - remove.addEventListener('click', onRemove); - undo.push(() => remove.removeEventListener('click', onRemove)); + /* + Spacing before Delete, and both on the right. - bar.append(label, shape, remove); + The order is how often they are wanted, not how they read: spacing is an ordinary adjustment and + deleting a band is not, so the destructive one sits furthest from where the hand arrives. + */ + const actions: HTMLElement[] = []; + for (const [text, run, first] of [ + ['Spacing', () => canvas.onSpaceSection(section), true], + ['Delete', () => canvas.onDeleteSection(section), false], + ] as const) { + const action = document.createElement('button'); + action.type = 'button'; + action.textContent = text; + style(action, [ + first ? 'margin-left:auto' : '', + 'cursor:pointer', + 'background:none', + 'border:0', + 'padding:0', + 'font:inherit', + 'color:inherit', + 'opacity:0.7', + 'text-decoration:underline', + ]); + action.addEventListener('click', run); + undo.push(() => action.removeEventListener('click', run)); + actions.push(action); + } + + bar.append(label, shape, ...actions); element.append(bar); undo.push(() => bar.remove()); @@ -294,6 +321,7 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void { for (const [label, run] of [ ['Edit', () => canvas.onEditComponent(component)], + ['Spacing', () => canvas.onSpaceComponent(component)], ['Delete', () => canvas.onDeleteComponent(component)], ] as const) { const action = document.createElement('button'); diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index c86f7f2..28835c1 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -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(null); const [sections, setSections] = useState([]); @@ -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([]); /* 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
{error}
; @@ -353,6 +419,18 @@ export const PageBuilderPanel = () => { title="Choose a video" /> + {spacing && ( + void saveSpacing(padding)} + onClose={() => setSpacing(null)} + /> + )} + {editing && ( >; + +const BREAKPOINT: Record = { mobile: 'base', tablet: 'md', desktop: 'lg' }; + +const inherited = (padding: Padding, breakpoint: 'base' | 'md' | 'lg', axis: 'x' | 'y'): number | null => { + const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const; + for (const smaller of below[breakpoint]) { + const step = padding[smaller]?.[axis]; + if (typeof step === 'number') return step; + } + return null; +}; + +export const SpacingEditor = ({ + label, + padding, + device, + steps, + busy, + onChange, + onClose, +}: { + /** What is being spaced — the band's name, or the kind of component. */ + label: string; + padding: Padding; + device: DeviceName; + steps: number[]; + busy: boolean; + onChange: (padding: Padding) => void; + onClose: () => void; +}) => { + const breakpoint = BREAKPOINT[device]; + + const set = (axis: 'x' | 'y', value: string) => { + const at = { ...(padding[breakpoint] ?? {}) }; + if (value === '') delete at[axis]; + else at[axis] = Number(value); + + const next = { ...padding }; + // An empty breakpoint is removed rather than kept as `{}`, so "inherit everything" stays an absence + // instead of becoming a thing that has to be told apart from one later. + if (Object.keys(at).length) next[breakpoint] = at; + else delete next[breakpoint]; + + onChange(next); + }; + + const axis = (name: 'x' | 'y', title: string) => { + const own = padding[breakpoint]?.[name]; + const from = inherited(padding, breakpoint, name); + + return ( +
+ + +
+ ); + }; + + return ( +
+
+

Spacing — {label}

+ + editing {DEVICES[device].label.toLowerCase()} — {breakpoint} + + +
+ +
+ {axis('x', 'Horizontal')} + {axis('y', 'Vertical')} +
+
+ ); +};