Panning went berserk. Wired straight to MediaField, `onCropChange` fired on every move, each move saved, each save reloaded the row, and the reload handed the crop back as the last SAVED value — yanking the picture out from under the drag. BlockMedia exists for exactly this and I went around it. It keeps the crop in a local draft, saves once when the drag stops, and resets on the url and the shape rather than on the crop — its own note says why, and the failure it describes is the one I reproduced. Also: an empty picture slot looked the same whether the carousel was still being read or genuinely had nothing in it. `undefined` now means loading and `null` means empty, and the line underneath says which. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
7.7 KiB
TypeScript
185 lines
7.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { RichText } from '../RichText';
|
|
import { useAutosave } from '../useAutosave';
|
|
import { BlockMedia } from './BlockMedia';
|
|
import { SHAPES } from './shapes';
|
|
import type { SubjectRecord, SubjectSection } from './subjects';
|
|
|
|
/**
|
|
* What somebody says about a collection or a piece, in the editor column.
|
|
*
|
|
* ── One form, two sections ──
|
|
*
|
|
* The fields below the description come from the section — see subjects.ts. Everything above them is
|
|
* shared because it is the same in both: what it is called, and where it lives.
|
|
*
|
|
* The carousel is not here. It is chosen beside the photographs it puts on screen, in the panel, where
|
|
* the consequence of choosing it is visible — a dropdown in a column of text fields would be the one
|
|
* control whose effect you cannot see.
|
|
*
|
|
* ── One draft, one save ──
|
|
*
|
|
* Every field writes into one draft object and `useAutosave` sends it whole. Per-field saves would be
|
|
* eight requests for somebody filling in a form, and the last one to land would win in an order nobody
|
|
* controls.
|
|
*/
|
|
export const SubjectFields = <T extends SubjectRecord>({
|
|
section,
|
|
record,
|
|
firstPhoto,
|
|
onSaved,
|
|
}: {
|
|
section: SubjectSection<T>;
|
|
record: T;
|
|
/** The first picture in the row's carousel — what the cover falls back to. */
|
|
firstPhoto?: string | null;
|
|
onSaved: () => void;
|
|
}) => {
|
|
const client = useClient();
|
|
const [draft, setDraft] = useState<Partial<Record<string, string>>>({});
|
|
|
|
// Keyed on the ID, not the values: keyed on the values it would fight the typing, pulling every
|
|
// keystroke back to the last saved one until the save landed.
|
|
useEffect(() => setDraft({}), [record.id]);
|
|
|
|
const value = (key: string): string =>
|
|
draft[key] ?? ((record as Record<string, unknown>)[key] as string | null) ?? '';
|
|
|
|
const dirty = Object.keys(draft).length > 0;
|
|
|
|
useAutosave(dirty ? JSON.stringify(draft) : null, false, async () => {
|
|
await client.patch(`${section.endpoint}/${record.id}`, draft);
|
|
setDraft({});
|
|
onSaved();
|
|
});
|
|
|
|
const set = (key: string) => (next: string) => setDraft((was) => ({ ...was, [key]: next }));
|
|
|
|
/*
|
|
The cover writes STRAIGHT through, not into the draft.
|
|
|
|
The draft exists so a person typing does not send a request per keystroke — see useAutosave above.
|
|
Choosing a picture and dragging a crop are single acts with a single outcome, so they save when
|
|
they happen; putting them in the draft would make a crop wait on a debounce and, worse, be
|
|
discarded by the `setDraft({})` that follows every save.
|
|
*/
|
|
const save = (values: Record<string, unknown>) => {
|
|
void client.patch(`${section.endpoint}/${record.id}`, values).then(onSaved);
|
|
};
|
|
|
|
/* Null means the carousel's first item, which the panel already knows — see SubjectPanel. */
|
|
const cover = record.cover_url ?? firstPhoto ?? null;
|
|
|
|
/* Tailwind's spelling, from the template's ratio. `1/1` when there is no shape, because a cropping
|
|
frame has to have SOME proportions and a square says least about the picture. */
|
|
const aspect =
|
|
record.cover_shape === null
|
|
? '1/1'
|
|
: (SHAPES.find((one) => one.ratio && Math.abs(one.ratio - record.cover_shape!) < 0.01)?.value ?? '1/1');
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Name</label>
|
|
<Input value={value('title')} onChange={(event) => set('title')(event.target.value)} />
|
|
</div>
|
|
|
|
{/*
|
|
The picture that represents this row, and how it is framed.
|
|
|
|
── Why the framing is HERE and the shape is not ──
|
|
|
|
The shape is one decision for the whole section and lives on the template — every collab is a
|
|
21:9 band or none of them is. Which part of THIS photograph survives that shape is a decision
|
|
about this photograph, and there is one per row. A single rectangle chosen against one lead and
|
|
applied to all of them is how a portrait ends up cropped through somebody's head.
|
|
|
|
── Why it can be empty ──
|
|
|
|
Null means "the first item of the carousel", which is the ordinary state and is not stored:
|
|
reorder the carousel and the lead follows, which is what dragging it there is for. Choosing one
|
|
here is the deliberate act of saying "not that one" — and it may be a picture the carousel does
|
|
not contain at all.
|
|
*/}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Main image</label>
|
|
{/*
|
|
`BlockMedia`, not `MediaField` directly.
|
|
|
|
It holds the crop in a local draft and saves once, when the drag stops. Wired straight to
|
|
`MediaField` this saved on every pointer move — and each save reloaded the row, which handed
|
|
the crop back as the last SAVED value and yanked the picture out from under the drag. That is
|
|
the exact failure the note at the top of BlockMedia describes, and the reason it exists.
|
|
*/}
|
|
<BlockMedia
|
|
url={cover}
|
|
crop={record.cover_crop}
|
|
aspect={aspect}
|
|
onUrl={(path) => save({ cover_url: path, ...(path ? {} : { cover_crop: null }) })}
|
|
onCrop={(crop) => save({ cover_crop: crop })}
|
|
/>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{record.cover_url
|
|
? 'Chosen for this one. Remove it to go back to the first picture in its carousel.'
|
|
: firstPhoto === undefined
|
|
? 'Reading its carousel…'
|
|
: firstPhoto
|
|
? 'From the first picture in its carousel. Choose one to override that.'
|
|
: 'Its carousel is empty. Choose a picture, or add some in Carousels.'}
|
|
{record.cover_shape === null && ' No shape is set on the page, so it draws at its own.'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Address</label>
|
|
<Input value={value('slug')} onChange={(event) => set('slug')(event.target.value)} />
|
|
<p className="font-mono text-[11px] text-muted-foreground/70">
|
|
{section.base}/{value('slug')}
|
|
</p>
|
|
</div>
|
|
|
|
{/*
|
|
Rich text, not a textarea.
|
|
|
|
It is the paragraph the template draws under the name — the one piece of writing about this
|
|
thing — so it wants the same headings, emphasis, links and alignment the page's own text
|
|
blocks have. `Prose` on the site already renders HTML when it finds any and falls back to
|
|
paragraphs when it does not, so what is already stored as plain text goes on working.
|
|
|
|
Sanitised on the way in by the API, against the same allowlist the page components use.
|
|
*/}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Description</label>
|
|
<RichText
|
|
value={value('description')}
|
|
onChange={(html) => set('description')(html)}
|
|
placeholder="What this is."
|
|
/>
|
|
</div>
|
|
|
|
{section.specs.map(([key, label, placeholder]) => (
|
|
<div key={key} className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">{label}</label>
|
|
{section.long?.includes(key) ? (
|
|
<Textarea
|
|
rows={3}
|
|
placeholder={placeholder}
|
|
value={value(key)}
|
|
onChange={(event) => set(key)(event.target.value)}
|
|
/>
|
|
) : (
|
|
<Input
|
|
placeholder={placeholder}
|
|
value={value(key)}
|
|
onChange={(event) => set(key)(event.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|