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>
246 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
};
|