Two defects found by reviewing the panel rather than the site.
── The lead photograph had a cropper that did nothing ──
It wrote `content.crop` on a subject-cover block. The site stopped reading that
when the crop moved to the row - one rectangle framed against one photograph and
applied to every row a template draws goes through the face of the next one - and
the control stayed behind. It saved, the API allowlisted it, and nothing rendered
it, under help text promising it applied to every row.
Gone, with the lead fetch that fed it. Shape stays: one decision for the whole
section, and it is read. The per-row cropper in each section's settings is the
one that works.
── A failed save said nothing at all ──
SubjectFields drives Collections, Pieces and Collabs. Neither of its save paths
had a failure branch, so a rejected PATCH was an unhandled rejection and a person
watching their own change had no way to know it was not written. There is no Save
button here by design, which makes reporting the failure the only signal there
is.
It matters most for the cover, which does not go through the draft: typing
survives, because the draft is only cleared on success and the next keystroke
retries, but a crop that fails is simply gone while the picture goes on showing
the rectangle that was dragged.
Also: the shape is computed once instead of three times from a fabricated
`{ url: 'x.png' }`, and the SHAPES import moved to the top of the file with the
others rather than sitting after a top-level const.
219 lines
9.1 KiB
TypeScript
219 lines
9.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
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>>>({});
|
|
|
|
/*
|
|
What went wrong, because otherwise nothing says.
|
|
|
|
Every save here is silent by design - there is no button to report back to, which is the whole
|
|
point of useAutosave. That is fine while it works and indefensible when it does not: the two paths
|
|
below had no failure branch at all, so a rejected PATCH was an unhandled promise and a person
|
|
watching their own change on screen had no way to know it had not been written.
|
|
|
|
It matters most for the cover, which does not go through the draft. Typing survives a failure -
|
|
the draft is only cleared on success, so the next keystroke tries again with a new value. A crop
|
|
that fails is simply gone, and the picture goes on showing the rectangle that was dragged.
|
|
*/
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 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 () => {
|
|
try {
|
|
await client.patch(`${section.endpoint}/${record.id}`, draft);
|
|
setError(null);
|
|
// Only on success: what is still here is what has not been written, and clearing it would throw
|
|
// away the typing along with the news that it was lost.
|
|
setDraft({});
|
|
onSaved();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
});
|
|
|
|
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(() => {
|
|
setError(null);
|
|
onSaved();
|
|
})
|
|
.catch((failure: unknown) => setError(readableError(failure)));
|
|
};
|
|
|
|
/* 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">
|
|
{error && (
|
|
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<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>
|
|
);
|
|
};
|