import { useEffect, useRef, useState } from 'react'; import { Input } from '@/components/ui/input'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { useAutosave } from '../useAutosave'; import { TitleSize } from './TitleSize'; import { useFlash } from '../useFlash'; /** * What a band is: what the editor calls it, and what the page shows. * * ── Two names, and they are different things ── * * The NAME is for whoever is editing — it is the row in the nav, and it is what makes a page of eight * bands navigable. It never reaches a reader. * * The TITLE is the heading the page draws above the band, and most bands do not want one: a band that * opens with its own words is the common case. Empty takes it off. * * Both are here rather than the title alone, because a form that shows one of two names invites * somebody to change the wrong one — and until now `name` could only be set when the band was created. */ export const BandSettings = ({ pageId, band, onSaved, blocks, }: { pageId: number; band: { id: number; name: string; title?: string | null; title_size?: string | null; divider?: boolean }; /** The builder re-reads its sections: the nav's row is this name, and the page draws this title. */ onSaved: () => void; /** This band's blocks — `BandBlocks`, built by the panel that has the handlers. */ blocks?: React.ReactNode; }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [draft, setDraft] = useState({ name: band.name, title: band.title ?? '' }); const [busyDivider, setBusyDivider] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, flashSaved] = useFlash(); // A different band selected is a different form. Keyed on the id so re-reading the same band after a // save does not throw away what is being typed. useEffect(() => setDraft({ name: band.name, title: band.title ?? '' }), [band.id]); const stored = JSON.stringify({ name: band.name, title: band.title ?? '' }); const pending = JSON.stringify(draft); const save = async () => { setBusy(true); setError(null); try { // An empty title is sent as an empty string, which the API reads as "take the heading off" — // presence, not truthiness. See the note in its PATCH handler. await clientRef.current.patch(`/fofos-admin/pages/${pageId}/sections/${band.id}`, draft); flashSaved(); onSaved(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; useAutosave(pending !== stored ? pending : null, busy, save); return (
{ // Read NOW. React nulls `currentTarget` when the handler returns, and a functional updater // runs after that — so reading it inside one throws on the first keystroke. const value = event.currentTarget.value; setDraft((was) => ({ ...was, name: value })); }} />
{ const value = event.currentTarget.value; setDraft((was) => ({ ...was, title: value })); }} />
{/* Beside the heading it sizes. Its own request, like the rule below — a select has one value and nothing to debounce. */} { void client .patch(`/fofos-admin/pages/${pageId}/sections/${band.id}`, { title_size: next ?? '' }) .then(onSaved); }} /> {/* Written on its own rather than through the draft, because it is a switch: there is nothing to debounce and waiting 600ms to see a line disappear reads as the control not working. Phrased as what it DOES — a rule under this band — rather than as `divider`, which is the column name and tells somebody nothing about what they will see. */} {saved &&

Saved

} {error &&

{error}

} {/* What is in this band, the same list the page's structure nests under every band. A band selected and its contents not shown would mean going back out to the page to add a block to the thing you are already looking at. */} {blocks}
); };