The measure was drawn on Layout only, and this is the screen where things are placed against it. It also answers the question a negative margin asks: a block escaping the gutter has to be seen escaping it, against the line it crossed. Horizontal padding now offers negatives, built from the same scale so there is still one list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
166 lines
6.3 KiB
TypeScript
166 lines
6.3 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';
|
|
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; 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 ?? '', 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 ?? '', 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 }));
|
|
};
|
|
|
|
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.title, padding: draft.padding },
|
|
);
|
|
flashSaved();
|
|
onSaved();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const stored = JSON.stringify({ title: block.title ?? '', 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) => setDraft((was) => ({ ...was, title: event.currentTarget.value }))}
|
|
/>
|
|
|
|
{/*
|
|
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>
|
|
{/*
|
|
Horizontal offers NEGATIVES, vertical does not.
|
|
|
|
A negative horizontal is how a block escapes the gutter its band applied — a video
|
|
that runs to the edge of the window inside an inset band. It becomes `-mx-*`, which
|
|
cancels the band's `px-*` exactly, so the value to pick is the band's own number.
|
|
|
|
Vertically there is nothing to cancel: a band's vertical padding is the room around
|
|
the whole band, and a block pulling itself into the one above is a bug.
|
|
*/}
|
|
{(axis === 'x'
|
|
? [...steps.filter((step) => step > 0).map((step) => -step).reverse(), ...steps]
|
|
: 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>
|
|
);
|
|
};
|