import { useEffect, useRef, useState } from 'react'; import { Input } from '@/components/ui/input'; import { TitleSize } from './TitleSize'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { useAutosave } from '../useAutosave'; import { useFlash } from '../useFlash'; import { useDevice } from '../DeviceToggle'; /** * A block's own heading. Optional, and above whatever editor the block has. * * ── Why it is not inside the free text ── * * Typing a heading into the text works for one kind of block and no others. A carousel, a video and a * partner row all want to announce themselves and have nowhere to put the words — so the heading is a * property of the BLOCK rather than of its contents, and every kind gets one. * * That is also why this is announced for every selected block, including the ones with no editor of * their own: `canEditComponent` answers "is there content to type", and a heading is not content. */ export const BlockSettings = ({ pageId, sectionId, block, steps, onSaved, }: { pageId: number; sectionId: number; block: { id: number; title?: string | null; title_size?: string | null; width?: string | null; padding?: Record; }; /** The padding scale, sent by the API so this file holds no copy of it. */ steps: number[]; onSaved: () => void; }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [device] = useDevice(); const breakpoint = ({ desktop: 'lg', tablet: 'md', mobile: 'base' } as const)[device]; const [draft, setDraft] = useState({ title: block.title ?? '', width: block.width ?? '', padding: block.padding ?? {}, }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); 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. useEffect( () => setDraft({ title: block.title ?? '', width: block.width ?? '', 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 })); }; /** * @param extra Fields written alongside the draft, for controls that are not part of it. * * The heading SIZE is one: a select has a single value and nothing to debounce, so waiting 600ms to * watch a heading change size would read as a control that has not responded. It still goes through * this function rather than its own request, so the draft is not left behind by a write that * happened next to it. */ const save = async (extra?: Record) => { setBusy(true); setError(null); try { // Empty is sent as an empty string, which the API reads as "take the heading off". await clientRef.current.patch( `/fofos-admin/pages/${pageId}/sections/${sectionId}/components/${block.id}`, { title: draft.title, width: draft.width || null, padding: draft.padding, ...extra }, ); flashSaved(); onSaved(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; const stored = JSON.stringify({ title: block.title ?? '', width: block.width ?? '', padding: block.padding ?? {}, }); const pending = JSON.stringify(draft); useAutosave(pending !== stored ? pending : null, busy, save); return (
{ // Read NOW — see the note in BandSettings. A functional updater runs after the handler has // returned, and `currentTarget` is null by then. const value = event.currentTarget.value; setDraft((was) => ({ ...was, title: value })); }} /> {/* Beside the heading it sizes. Its own request rather than part of the draft: a select has one value and nothing to debounce, and waiting to see a heading change size reads as a control that has not responded. */} void save({ title_size: next ?? '' })} /> {/* How wide it is allowed to be. It was a NEGATIVE horizontal padding, which worked and asked the wrong thing: you had to know the band's gutter and type its opposite, and it broke quietly the day somebody changed the gutter. These two say what is wanted, and the page works out the number. */}
{/* 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. */}
{( [ ['x', 'Horizontal padding'], ['y', 'Vertical padding'], ] as const ).map(([axis, label]) => { const own = draft.padding[breakpoint]?.[axis]; const from = inherited(axis); return (
); })}
{saved &&

Saved

} {error &&

{error}

}
); };