Files
pastilhasandClaude Opus 5 76a749d8f9 an Alignment control beside Width
Left, Centred, Right on the selected block. Left writes nothing, so a block that
has never been touched inherits the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:53:44 +01:00

246 lines
9.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Input } from '@/components/ui/input';
import { TitleSize } from './TitleSize';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useAutosave } from '../useAutosave';
import { useFlash } from '../useFlash';
import { useDevice } from '../DeviceToggle';
/**
* 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 carousel, 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,
steps,
onSaved,
}: {
pageId: number;
sectionId: number;
block: {
id: number;
title?: string | null;
title_size?: string | null;
width?: string | null;
align?: string | null;
padding?: Record<string, { x?: number; y?: number }>;
};
/** The padding scale, sent by the API so this file holds no copy of it. */
steps: number[];
onSaved: () => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [device] = useDevice();
const breakpoint = ({ desktop: 'lg', tablet: 'md', mobile: 'base' } as const)[device];
const [draft, setDraft] = useState({
title: block.title ?? '',
width: block.width ?? '',
align: block.align ?? '',
padding: block.padding ?? {},
});
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({ title: block.title ?? '', width: block.width ?? '', align: block.align ?? '', padding: block.padding ?? {} }),
[block.id],
);
/** What an axis falls back to at this width: the nearest smaller one that has a value. */
const inherited = (axis: 'x' | 'y'): number | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[breakpoint]) {
const step = draft.padding[smaller]?.[axis];
if (typeof step === 'number') return step;
}
return null;
};
const setPadding = (axis: 'x' | 'y', value: string) => {
const at = { ...(draft.padding[breakpoint] ?? {}) };
if (value === '') delete at[axis];
else at[axis] = Number(value);
const next = { ...draft.padding };
// An empty breakpoint is removed rather than kept as `{}`, so "inherit everything" stays an
// absence instead of a thing that has to be told apart from one later.
if (Object.keys(at).length) next[breakpoint] = at;
else delete next[breakpoint];
setDraft((was) => ({ ...was, padding: next }));
};
/**
* @param extra Fields written alongside the draft, for controls that are not part of it.
*
* The heading SIZE is one: a select has a single value and nothing to debounce, so waiting 600ms to
* watch a heading change size would read as a control that has not responded. It still goes through
* this function rather than its own request, so the draft is not left behind by a write that
* happened next to it.
*/
const save = async (extra?: Record<string, unknown>) => {
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.title, width: draft.width || null, align: draft.align || null, padding: draft.padding, ...extra },
);
flashSaved();
onSaved();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const stored = JSON.stringify({
title: block.title ?? '',
width: block.width ?? '',
align: block.align ?? '',
padding: block.padding ?? {},
});
const pending = JSON.stringify(draft);
useAutosave(pending !== stored ? pending : null, busy, save);
return (
<div className="flex flex-col gap-1">
<label className="text-xs">Title</label>
<Input
value={draft.title}
placeholder="No heading"
onChange={(event) => {
// Read NOW — see the note in BandSettings. A functional updater runs after the handler has
// returned, and `currentTarget` is null by then.
const value = event.currentTarget.value;
setDraft((was) => ({ ...was, title: value }));
}}
/>
{/* Beside the heading it sizes. Its own request rather than part of the draft: a select has one
value and nothing to debounce, and waiting to see a heading change size reads as a control
that has not responded. */}
<TitleSize
value={block.title_size}
disabled={!draft.title.trim()}
onChange={(next) => void save({ title_size: next ?? '' })}
/>
{/*
How wide it is allowed to be.
It was a NEGATIVE horizontal padding, which worked and asked the wrong thing: you had to know
the band's gutter and type its opposite, and it broke quietly the day somebody changed the
gutter. These two say what is wanted, and the page works out the number.
*/}
<div className="mt-3 flex flex-col gap-1">
<label className="text-xs">Width</label>
<select
value={draft.width}
onChange={(event) => {
const value = event.currentTarget.value;
setDraft((was) => ({ ...was, width: value }));
}}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
<option value="">Inside the band</option>
<option value="main">Whole width of main</option>
<option value="page">Whole page width</option>
</select>
</div>
{/*
How the block's own content sits.
A property of the BLOCK, not of the words in it: it applies to a title drawn from the subject,
a spec table and a photograph, none of which have text you could select and centre. The rich
text editor still has its own alignment for a paragraph inside a free-text block, and the two
are different questions.
Left is the absence of a value rather than a value, so a block that has never been touched
inherits the page and nothing is written for it.
*/}
<div className="mt-3 flex flex-col gap-1">
<label className="text-xs">Alignment</label>
<select
value={draft.align}
onChange={(event) => {
const value = event.currentTarget.value;
setDraft((was) => ({ ...was, align: value }));
}}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
<option value="">Left</option>
<option value="center">Centred</option>
<option value="right">Right</option>
</select>
</div>
{/*
The block's own spacing, at the width the toggle above is showing.
It had a Spacing link in the chrome painted onto the page, which went with the rest of it. It
belongs here: it is a property of the selected block, like the heading, and one of the two is
not worth a separate place to go.
Unset inherits the smaller width, and 0 is the other instruction — none HERE even though the
phone has some. So they are separate options.
*/}
<div className="mt-3 grid grid-cols-2 gap-3">
{(
[
['x', 'Horizontal padding'],
['y', 'Vertical padding'],
] as const
).map(([axis, label]) => {
const own = draft.padding[breakpoint]?.[axis];
const from = inherited(axis);
return (
<div key={axis} className="flex flex-col gap-1">
<label className="text-xs">{label}</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => setPadding(axis, event.currentTarget.value)}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
<option value="">
{breakpoint === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
</div>
);
})}
</div>
{saved && <p className="text-xs text-success">Saved</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
};