Files
fofos-admin/web/sections/SubjectFields.tsx
T
pastilhasandClaude Opus 5 5968cb5d83 each row picks its own main image, and frames it
The shape is one decision for the whole section and stays 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, which is what the block-level crop did
and why it is gone.

So a row's settings now carry Main image: a picture and a cropping frame in the
proportions the page asked for. Empty means the first item of its carousel, which
is the ordinary state and is not stored — reorder the carousel and the lead
follows. Choosing one is the deliberate act of saying "not that one", and it may
be a picture the carousel does not contain at all.

`cover_shape` rides along with each row so this panel can draw the right frame
without knowing how a template is assembled. SHAPES moved out of PageBuilderPanel
because two screens offer them now, and two lists is two places to add the next
ratio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:41:37 +01:00

174 lines
7.1 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 { MediaField } from '../MediaField';
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>
<MediaField
value={cover}
onChange={(path) => save({ cover_url: path, ...(path ? {} : { cover_crop: null }) })}
crop={record.cover_crop}
onCropChange={(crop) => save({ cover_crop: crop })}
aspect={aspect}
label="Choose the main image"
/>
<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.'
: 'From the first picture in its carousel. Choose one to override that.'}
{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>
);
};