Files
fofos-admin/web/sections/BandSettings.tsx
T
pastilhasandClaude Opus 5 6781fd5875 a band chooses whether a rule follows it
The site separates bands with an edge-to-edge line, and it was `index < sections.length - 1` — not a
decision anybody could make. Two bands that belong together, a heading beside its picture and the
carousel that continues it, read as two pages with a rule between them.

Putting both in ONE band does not solve it: a band is one row, so a full-width strip inside a
two-column band overlaps the other column. What was wanted was never one section; it was no line.

A checkbox rather than a field in the draft, because it is a switch — there is nothing to debounce, and
waiting 600ms to watch a line disappear reads as the control not working. Labelled by what it does
rather than as `divider`, which is the column name and says nothing about what you will see.

Still never on the last band: a line above the footer is the footer's own border, twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:20:06 +01:00

137 lines
5.0 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 { 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; 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>
{/*
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>
);
};