Files
fofos-admin/web/sections/BlockSettings.tsx
T
pastilhasandClaude Opus 5 44a907f3b1 a heading can say which type role it is
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>
2026-09-04 16:49:01 +01:00

216 lines
7.8 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 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,
steps,
onSaved,
}: {
pageId: number;
sectionId: number;
block: {
id: number;
title?: string | null;
title_size?: string | null;
width?: 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 ?? '',
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 ?? '', 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, padding: draft.padding, ...extra },
);
flashSaved();
onSaved();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const stored = JSON.stringify({
title: block.title ?? '',
width: block.width ?? '',
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>
{/*
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>
);
};