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>
This commit is contained in:
2026-09-05 15:41:37 +01:00
co-authored by Claude Opus 5
parent c6cebc5000
commit 5968cb5d83
5 changed files with 105 additions and 19 deletions
+62
View File
@@ -4,6 +4,8 @@ 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';
/**
@@ -27,10 +29,13 @@ import type { SubjectRecord, SubjectSection } from './subjects';
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();
@@ -53,6 +58,28 @@ export const SubjectFields = <T extends SubjectRecord>({
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">
@@ -60,6 +87,41 @@ export const SubjectFields = <T extends SubjectRecord>({
<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)} />