diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index 563c336..bc8c346 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -7,6 +7,7 @@ import { useSaveTarget } from '../SaveTarget'; import { usePreviewable, useViewMode } from '../ViewMode'; import { paintMeasure } from '../canvas'; import { SitePreview, useDevPort } from '../SitePreview'; +import { PaddingTab, type LayoutBand, type SectionPadding } from './PaddingTab'; /** * The shell every page renders inside: the menu, the footer, the colours and the typefaces. @@ -49,6 +50,11 @@ type Layout = { font_display: string | null; /** What the picker may offer — sent by the API so this file holds no third copy of the list. */ fonts: string[]; + /** The layout PAGE and its three bands. A different thing from the colours above — see the API. */ + layout_page_id: number | null; + sections: LayoutBand[]; + /** The steps a padding may take, sent by the API so this file holds no copy of the scale. */ + padding_steps: number[]; /** * Which face plays each role when nothing has been chosen, read from the site's own stylesheet. * @@ -116,6 +122,8 @@ export const LayoutPanel = () => { const devPort = useDevPort(); /** Controlled, because the grid stack below decides visibility rather than Radix unmounting. */ const [tab, setTab] = useState('colours'); + /** Which band the Padding tab is editing. Null until the payload has arrived. */ + const [band, setBand] = useState(null); const [error, setError] = useState(null); const [saved, flashSaved] = useFlash(); @@ -140,11 +148,23 @@ export const LayoutPanel = () => { /* Compared field by field rather than whole, because the payload carries something this does not own: - `fonts` and `font_defaults` are the API describing the site — which faces the build subsets, and + `fonts`, `font_defaults` and `padding_steps` are the API describing the site — which faces the build subsets, and which one plays each role today — not values anybody edits here. Including them would be harmless today and wrong the first time either changed: the panel would show unsaved changes for a decision made in a build script or a stylesheet. */ + /* + Padding is drafted here and written by the same Save, through the SECTIONS endpoint. + + Two endpoints, one button. The alternative was a second Save for spacing, which would mean somebody + changing a colour and a padding and having to press two things — and being told they had unsaved + changes by only one of them. + */ + const setPadding = (id: number, padding: SectionPadding) => + setDraft((was) => + was ? { ...was, sections: was.sections.map((one) => (one.id === id ? { ...one, padding } : one)) } : was, + ); + const WRITABLE = [ 'background', 'foreground', @@ -158,7 +178,9 @@ export const LayoutPanel = () => { const only = (source: Layout | null) => source ? JSON.stringify(Object.fromEntries(WRITABLE.map((key) => [key, source[key]]))) : null; - const dirty = Boolean(stored && draft) && only(stored) !== only(draft); + const paddings = (source: Layout | null) => JSON.stringify(source?.sections.map((one) => one.padding) ?? null); + + const dirty = Boolean(stored && draft) && (only(stored) !== only(draft) || paddings(stored) !== paddings(draft)); const save = async () => { if (!draft) return; @@ -166,6 +188,21 @@ export const LayoutPanel = () => { setError(null); try { const body = Object.fromEntries(WRITABLE.map((key) => [key, draft[key]])); + + // The bands that actually changed, before the layout write, so a failure on one leaves the other + // untouched rather than half-applied. + if (draft.layout_page_id) { + for (const one of draft.sections) { + const was = stored?.sections.find((other) => other.id === one.id); + if (was && JSON.stringify(was.padding) !== JSON.stringify(one.padding)) { + await clientRef.current.patch( + `/fofos-admin/pages/${draft.layout_page_id}/sections/${one.id}`, + { padding: one.padding }, + ); + } + } + } + apply((await clientRef.current.patch<{ data: Layout }>('/fofos-admin/layout', body)).data); flashSaved(); } catch (failure) { @@ -178,7 +215,7 @@ export const LayoutPanel = () => { useSaveTarget(dirty, busy, save); // This section has a preview, so the bar offers Edit/View and the three widths. usePreviewable(); - const { mode } = useViewMode(); + const { mode, device } = useViewMode(); /* The measure, and nothing else. @@ -243,6 +280,9 @@ export const LayoutPanel = () => { Typefaces + + Padding + {/* @@ -340,6 +380,20 @@ export const LayoutPanel = () => { + +
+ +
)} diff --git a/web/sections/PaddingTab.tsx b/web/sections/PaddingTab.tsx new file mode 100644 index 0000000..4993281 --- /dev/null +++ b/web/sections/PaddingTab.tsx @@ -0,0 +1,142 @@ +import { Button } from '@/components/ui/button'; +import { DEVICES, type DeviceName } from '../ViewMode'; + +/** + * Padding, per section, per breakpoint. + * + * ── The device toggle IS the breakpoint selector ── + * + * Editing all three widths at once would be six controls per section, and five of them would be for a + * width you are not looking at. Instead the bar's Desktop / Tablet / Mobile decides which breakpoint + * these two controls write to — so you switch to Mobile, see the phone, and change the padding you can + * see changing. + * + * The mapping is Tailwind's, and it lines up with the three preview widths exactly: + * + * Mobile 390 base every width + * Tablet 834 md 768 and up + * Desktop 1440 lg 1024 and up + * + * ── Inherit is a real value, and not the same as zero ── + * + * They cascade, so an unset breakpoint takes the smaller one's value. That is what makes the common + * case — one padding everywhere — a single choice rather than three. Zero is the opposite instruction: + * no padding HERE even though the smaller width has some. Both have to be expressible, so "Inherit" + * and "0" are separate options rather than one meaning both. + */ + +export type SectionPadding = Partial>; +export type LayoutBand = { id: number; name: string; padding: SectionPadding }; + +/** Which breakpoint a device is looking at. See the table above. */ +export const BREAKPOINT: Record = { + mobile: 'base', + tablet: 'md', + desktop: 'lg', +}; + +/** What a breakpoint inherits when an axis is unset: the nearest smaller one that has a value. */ +const inherited = (padding: SectionPadding, 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 PaddingTab = ({ + bands, + device, + steps, + selected, + onSelect, + onChange, +}: { + bands: LayoutBand[]; + device: DeviceName; + /** The scale, sent by the API so this file holds no copy of it. */ + steps: number[]; + selected: number | null; + onSelect: (id: number) => void; + onChange: (id: number, padding: SectionPadding) => void; +}) => { + const breakpoint = BREAKPOINT[device]; + const band = bands.find((one) => one.id === selected) ?? bands[0]; + + if (!band) { + // Before the layout has been seeded. Said plainly rather than shown as an empty form somebody + // would try to use. + return

The layout has no sections yet.

; + } + + const set = (axis: 'x' | 'y', value: string) => { + const at = { ...(band.padding[breakpoint] ?? {}) }; + if (value === '') delete at[axis]; + else at[axis] = Number(value); + + const next = { ...band.padding }; + // An empty breakpoint is REMOVED rather than left as `{}` — otherwise "inherit everything" would + // be stored as a thing rather than as an absence, and the two would have to be told apart later. + if (Object.keys(at).length) next[breakpoint] = at; + else delete next[breakpoint]; + + onChange(band.id, next); + }; + + const axis = (name: 'x' | 'y', label: string) => { + const own = band.padding[breakpoint]?.[name]; + const from = inherited(band.padding, breakpoint, name); + + return ( +
+ + +
+ ); + }; + + return ( +
+
+ {bands.map((one) => ( + + ))} + + {/* Which width is being edited, said out loud. The device buttons are at the top of the panel + and the effect of pressing one is not obvious from down here. */} + + editing {DEVICES[device].label.toLowerCase()} — {breakpoint} + +
+ +
+ {axis('x', 'Horizontal')} + {axis('y', 'Vertical')} +
+
+ ); +};