Every heading — a band's and a block's — was `type-title`, so the only way to make one bigger was to resize Headings for the whole site. A ROLE rather than a size. The four are named and sized under Layout, so a heading set to Titles follows whatever Titles is set to and keeps following it. Offering point sizes here would be a second place to change one, and the two would part company the first time somebody used the panel that exists for it. Same `ROLES` list Fonts and Font Sizes read, so all three name the same four things in the same order. Choosing Headings stores nothing. A heading nobody has thought about and one deliberately set to the default should not be two different rows. Disabled with no heading, because the size of a heading that does not exist is not a question — and written on its own rather than through the draft, because a select has one value and waiting 600ms to watch a heading change size reads as a control that has not responded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
5.5 KiB
TypeScript
150 lines
5.5 KiB
TypeScript
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<string | null>(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 (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs">Name</label>
|
|
<Input
|
|
value={draft.name}
|
|
onChange={(event) => {
|
|
// 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 }));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs">Title</label>
|
|
<Input
|
|
value={draft.title}
|
|
placeholder="No heading"
|
|
onChange={(event) => {
|
|
const value = event.currentTarget.value;
|
|
setDraft((was) => ({ ...was, title: value }));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Beside the heading it sizes. Its own request, like the rule below — a select has one value and
|
|
nothing to debounce. */}
|
|
<TitleSize
|
|
value={band.title_size}
|
|
disabled={!draft.title.trim()}
|
|
onChange={(next) => {
|
|
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.
|
|
*/}
|
|
<label className="flex cursor-pointer items-center gap-2 text-xs">
|
|
<input
|
|
type="checkbox"
|
|
className="size-3.5 accent-primary"
|
|
checked={band.divider !== false}
|
|
disabled={busyDivider}
|
|
onChange={async (event) => {
|
|
const next = event.currentTarget.checked;
|
|
setBusyDivider(true);
|
|
try {
|
|
await client.patch(`/fofos-admin/pages/${pageId}/sections/${band.id}`, { divider: next });
|
|
onSaved();
|
|
} finally {
|
|
setBusyDivider(false);
|
|
}
|
|
}}
|
|
/>
|
|
A rule beneath this band
|
|
</label>
|
|
|
|
{saved && <p className="text-xs text-success">Saved</p>}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
{/*
|
|
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}
|
|
</div>
|
|
);
|
|
};
|