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:
@@ -55,6 +55,7 @@ const PLACES_ONLY: string[] = [
|
|||||||
'subject-specs',
|
'subject-specs',
|
||||||
'subject-carousel',
|
'subject-carousel',
|
||||||
];
|
];
|
||||||
|
import { SHAPES } from './shapes';
|
||||||
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
||||||
import { FreeTextEditor } from './FreeTextEditor';
|
import { FreeTextEditor } from './FreeTextEditor';
|
||||||
import { SpacingEditor, type Padding } from './SpacingEditor';
|
import { SpacingEditor, type Padding } from './SpacingEditor';
|
||||||
@@ -67,23 +68,7 @@ import { BlockMedia } from './BlockMedia';
|
|||||||
import { PageStructure } from './PageStructure';
|
import { PageStructure } from './PageStructure';
|
||||||
import { BandBlocks } from './BandBlocks';
|
import { BandBlocks } from './BandBlocks';
|
||||||
|
|
||||||
/**
|
|
||||||
* The shapes a photograph block may take.
|
|
||||||
*
|
|
||||||
* One list, because three things need it and must agree: the select's options, the number stored as
|
|
||||||
* `content.ratio`, and the Tailwind aspect `MediaField` frames into. Two of those are strings and one
|
|
||||||
* is a float, which is exactly the drift a single table prevents.
|
|
||||||
*/
|
|
||||||
const SHAPES = [
|
|
||||||
{ value: '', label: "The picture's own shape", ratio: null as number | null },
|
|
||||||
{ value: '21/9', label: '21:9 — a letterbox band', ratio: 21 / 9 },
|
|
||||||
{ value: '16/9', label: '16:9', ratio: 16 / 9 },
|
|
||||||
{ value: '3/2', label: '3:2', ratio: 3 / 2 },
|
|
||||||
{ value: '4/3', label: '4:3', ratio: 4 / 3 },
|
|
||||||
{ value: '1/1', label: '1:1 — square', ratio: 1 },
|
|
||||||
{ value: '3/4', label: '3:4 — upright', ratio: 3 / 4 },
|
|
||||||
{ value: '2/3', label: '2:3 — upright', ratio: 2 / 3 },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Stored as a float, matched with a tolerance: 21/9 does not survive a round trip exactly. */
|
/** Stored as a float, matched with a tolerance: 21/9 does not survive a round trip exactly. */
|
||||||
const closeTo = (a: number, b: unknown): boolean => typeof b === 'number' && Math.abs(a - b) < 0.01;
|
const closeTo = (a: number, b: unknown): boolean => typeof b === 'number' && Math.abs(a - b) < 0.01;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { RichText } from '../RichText';
|
import { RichText } from '../RichText';
|
||||||
import { useAutosave } from '../useAutosave';
|
import { useAutosave } from '../useAutosave';
|
||||||
|
import { MediaField } from '../MediaField';
|
||||||
|
import { SHAPES } from './shapes';
|
||||||
import type { SubjectRecord, SubjectSection } from './subjects';
|
import type { SubjectRecord, SubjectSection } from './subjects';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,10 +29,13 @@ import type { SubjectRecord, SubjectSection } from './subjects';
|
|||||||
export const SubjectFields = <T extends SubjectRecord>({
|
export const SubjectFields = <T extends SubjectRecord>({
|
||||||
section,
|
section,
|
||||||
record,
|
record,
|
||||||
|
firstPhoto,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
section: SubjectSection<T>;
|
section: SubjectSection<T>;
|
||||||
record: T;
|
record: T;
|
||||||
|
/** The first picture in the row's carousel — what the cover falls back to. */
|
||||||
|
firstPhoto?: string | null;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
@@ -53,6 +58,28 @@ export const SubjectFields = <T extends SubjectRecord>({
|
|||||||
|
|
||||||
const set = (key: string) => (next: string) => setDraft((was) => ({ ...was, [key]: next }));
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<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)} />
|
<Input value={value('title')} onChange={(event) => set('title')(event.target.value)} />
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Address</label>
|
<label className="text-xs text-muted-foreground">Address</label>
|
||||||
<Input value={value('slug')} onChange={(event) => set('slug')(event.target.value)} />
|
<Input value={value('slug')} onChange={(event) => set('slug')(event.target.value)} />
|
||||||
|
|||||||
@@ -116,9 +116,18 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditorRef.current(
|
setEditorRef.current(
|
||||||
selected ? <SubjectFields section={section} record={selected} onSaved={() => void load()} /> : null,
|
selected ? (
|
||||||
|
<SubjectFields
|
||||||
|
section={section}
|
||||||
|
record={selected}
|
||||||
|
// What the cover falls back to when the row has not chosen one — the same first item the
|
||||||
|
// grid below is already showing, so the two cannot disagree about which picture that is.
|
||||||
|
firstPhoto={photos?.[0]?.path ?? null}
|
||||||
|
onSaved={() => void load()}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
);
|
);
|
||||||
}, [selected, load, section]);
|
}, [selected, load, section, photos]);
|
||||||
|
|
||||||
useEffect(() => () => setEditorRef.current(null), []);
|
useEffect(() => () => setEditorRef.current(null), []);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* The shapes a slot can be framed into, in one place.
|
||||||
|
*
|
||||||
|
* Shared because two screens now offer them: the page builder sets the shape on a template's lead
|
||||||
|
* block, and a row's settings draw a cropping frame of those proportions. Two lists would be two
|
||||||
|
* places to add the next ratio, and the day they disagree a crop is framed against a shape the page
|
||||||
|
* does not use.
|
||||||
|
*/
|
||||||
|
export const SHAPES = [
|
||||||
|
{ value: '', label: "The picture's own shape", ratio: null as number | null },
|
||||||
|
{ value: '21/9', label: '21:9 — a letterbox band', ratio: 21 / 9 },
|
||||||
|
{ value: '16/9', label: '16:9', ratio: 16 / 9 },
|
||||||
|
{ value: '3/2', label: '3:2', ratio: 3 / 2 },
|
||||||
|
{ value: '4/3', label: '4:3', ratio: 4 / 3 },
|
||||||
|
{ value: '1/1', label: '1:1 — square', ratio: 1 },
|
||||||
|
{ value: '3/4', label: '3:4 — upright', ratio: 3 / 4 },
|
||||||
|
{ value: '2/3', label: '2:3 — upright', ratio: 2 / 3 },
|
||||||
|
];
|
||||||
@@ -32,6 +32,18 @@ export type SubjectRecord = {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
photo_count: number;
|
photo_count: number;
|
||||||
|
/** The row's own lead, or null meaning "the first item of its carousel". */
|
||||||
|
cover_url: string | null;
|
||||||
|
/** How that picture is framed, `x,y,w,h` in percentages. Null is centred. */
|
||||||
|
cover_crop: string | null;
|
||||||
|
/**
|
||||||
|
* The shape the template frames a lead into, or null for the picture's own.
|
||||||
|
*
|
||||||
|
* Read off the page, not the row — one decision for the whole section — and sent with the row so
|
||||||
|
* this panel can draw a cropping frame of the right proportions without knowing how a template is
|
||||||
|
* put together.
|
||||||
|
*/
|
||||||
|
cover_shape: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Collection = SubjectRecord & {
|
export type Collection = SubjectRecord & {
|
||||||
|
|||||||
Reference in New Issue
Block a user