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')} +
+
+ ); +};