canEditComponent answers "is there content to type", and a heading is not content — so the blocks with no editor of their own are now selectable too, and get the heading alone. The live draft writes into [data-block] when there is one. A titled component renders as a wrapper, and writing into the wrapper would replace the heading with the paragraph as somebody typed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
72 lines
2.4 KiB
TypeScript
72 lines
2.4 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';
|
|
|
|
/**
|
|
* 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 gallery, 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,
|
|
onSaved,
|
|
}: {
|
|
pageId: number;
|
|
sectionId: number;
|
|
block: { id: number; title?: string | null };
|
|
onSaved: () => void;
|
|
}) => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [draft, setDraft] = useState(block.title ?? '');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(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(block.title ?? ''), [block.id]);
|
|
|
|
const save = async () => {
|
|
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 },
|
|
);
|
|
flashSaved();
|
|
onSaved();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
useAutosave(draft !== (block.title ?? '') ? draft : null, busy, save);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs">Title</label>
|
|
<Input value={draft} placeholder="No heading" onChange={(event) => setDraft(event.currentTarget.value)} />
|
|
{saved && <p className="text-xs text-success">Saved</p>}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
};
|