Selecting a band showed its name and title and nothing about what was in it — so adding a block to the thing you were looking at meant going back out to the page. BandBlocks is that list, used by the band's editor and nested under every band in the page's structure. Same list, same actions, one component; the galleries panel taught that lesson once already. Blocks reorder within a column now. Across columns is the same call — the write carries the column — but a list per column has no gesture for crossing between them, and inventing one here would be guessing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
109 lines
3.9 KiB
TypeScript
109 lines
3.9 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 };
|
|
/** 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 [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>
|
|
|
|
{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>
|
|
);
|
|
};
|