Files
fofos-admin/web/sections/SubjectPanel.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

308 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Images, Loader2, Plus, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CarouselPicker } from '../CarouselPicker';
import { readableError } from '../errors';
import { useEditorSlot } from '../useEditorSlot';
import { mediaThumbUrl } from '../thumbnail';
import { SubjectFields } from './SubjectFields';
import type { SubjectPhoto, SubjectRecord, SubjectSection } from './subjects';
/**
* A list of things, each with a gallery and a form — Collections, and the Gallery's pieces.
*
* One panel for both. They are the same object edited the same way, and what differs is small enough
* to be a configuration: see subjects.ts, which is also where the reasoning lives.
*
* ── The rows are EDITORIAL ──
*
* A row exists because somebody made it, not because a folder does. An earlier version read
* `originals/COLLECTIONS/` and registered every directory it found, which meant whoever last tidied a
* folder decided the website's structure.
*
* ── The grid is READ-ONLY ──
*
* The photographs belong to the carousel, and so does their order — including which one leads, which
* is simply the first. Making them look pressable here would promise a control that lives in
* Carousels.
*/
export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: SubjectSection<T> }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const Icon = section.icon;
const [records, setRecords] = useState<T[] | null>(null);
const [photos, setPhotos] = useState<SubjectPhoto[] | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState('');
const [picking, setPicking] = useState(false);
// Selection in the URL, like every other screen here — see docs/navigation-audit.md.
const [params, setParams] = useSearchParams();
const selectedId = Number(params.get(section.param)) || null;
const selected = records?.find((one) => one.id === selectedId) ?? null;
const load = useCallback(async () => {
try {
const response = await clientRef.current.get<{ data: T[] }>(section.endpoint);
setRecords(response.data);
} catch (failure) {
setError(readableError(failure));
setRecords([]);
}
}, [section.endpoint]);
useEffect(() => {
void load();
}, [load]);
/*
A selection naming something that is not there any more.
The id lives in the URL, so it outlives the row: delete one with it open, reload, and the address
still asks for it. Left alone that became a 404 from the media fetch, rendered as "Not found" over
a panel that was otherwise working — an error about the URL, phrased as an error about the server.
Dropped rather than reported. There is nothing for anybody to do about it and the correct state is
simply "nothing selected".
*/
useEffect(() => {
if (!records || !selectedId) return;
if (records.some((one) => one.id === selectedId)) return;
setParams(
(current) => {
const next = new URLSearchParams(current);
next.delete(section.param);
return next;
},
{ replace: true },
);
}, [records, selectedId, setParams, section.param]);
// The carousel's photographs, re-read when the selection moves. Null while nothing is chosen, so the
// empty state and "still loading" are not the same thing.
useEffect(() => {
if (!selectedId || !selected) {
setPhotos(null);
return;
}
let alive = true;
setPhotos(null);
// Cleared here rather than only on success: an error from the last selection has nothing to say
// about this one, and leaving it up reads as the new one having failed too.
setError(null);
void clientRef.current
.get<{ data: SubjectPhoto[] }>(`${section.endpoint}/${selectedId}/media`)
.then((response) => alive && setPhotos(response.data))
.catch((failure: unknown) => alive && setError(readableError(failure)));
return () => {
alive = false;
};
}, [selectedId, selected, section.endpoint]);
/*
The fields, in the editor column — a SIBLING panel, so they are announced rather than rendered.
See useEditorSlot.
*/
const [, setEditor] = useEditorSlot();
const setEditorRef = useRef(setEditor);
setEditorRef.current = setEditor;
useEffect(() => {
setEditorRef.current(
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, photos]);
useEffect(() => () => setEditorRef.current(null), []);
const select = (id: number) =>
setParams(
(current) => {
const next = new URLSearchParams(current);
next.set(section.param, String(id));
return next;
},
{ replace: true },
);
const send = async (call: () => Promise<unknown>) => {
setBusy(true);
setError(null);
try {
await call();
await load();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const create = () =>
send(async () => {
const created = await clientRef.current.post<{ data: T }>(section.endpoint, { title: name.trim() });
setName('');
// Straight to it: making one and then having to find it in the list is a step nobody wants.
select(created.data.id);
});
const remove = () =>
send(async () => {
if (!selected) return;
await clientRef.current.delete(`${section.endpoint}/${selected.id}`);
setParams(
(current) => {
const next = new URLSearchParams(current);
next.delete(section.param);
return next;
},
{ replace: true },
);
});
const chooseCarousel = (carouselId: number) =>
send(async () => {
if (!selected) return;
setPicking(false);
await clientRef.current.patch(`${section.endpoint}/${selected.id}`, { carousel_id: carouselId });
});
return (
<div className="flex size-full min-h-0">
<div className="flex w-64 shrink-0 flex-col border-r border-border">
<ul className="min-h-0 flex-1 overflow-y-auto">
{records === null ? (
<li className="p-4 text-xs text-muted-foreground">Reading them…</li>
) : records.length === 0 ? (
<li className="p-4 text-xs text-muted-foreground">Nothing here yet.</li>
) : (
records.map((record) => (
<li key={record.id}>
<button
type="button"
onClick={() => select(record.id)}
className={`flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40 ${
record.id === selectedId ? 'bg-muted/60' : ''
}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{record.title}</span>
{/* The count, because one with no photographs looks identical to one nobody has
opened. */}
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{record.photo_count}
</span>
</button>
</li>
))
)}
</ul>
{/* Creating is a name and a button, at the bottom of the list it adds to. A dialog for one
field is a dialog nobody thanks you for. */}
<div className="flex items-center gap-2 border-t border-border p-2">
<Input
value={name}
placeholder={`New ${section.noun}`}
className="h-8 text-xs"
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && name.trim()) void create();
}}
/>
<Button size="sm" className="h-8 px-2" disabled={busy || !name.trim()} onClick={() => void create()}>
<Plus className="size-3.5" />
</Button>
</div>
</div>
<div className="min-w-0 flex-1 overflow-y-auto p-4">
{error && <p className="mb-3 text-xs text-destructive">{error}</p>}
{selected && (
<div className="mb-4 flex flex-wrap items-center gap-2 border-b border-border pb-3">
<Button size="sm" variant="outline" className="h-8 gap-1.5 text-xs" onClick={() => setPicking(true)}>
<Images className="size-3.5" />
{selected.carousel_id ? 'Change carousel' : 'Choose a carousel'}
</Button>
<span className="min-w-0 flex-1" />
{/* Deleting removes the ROW, never the photographs — they belong to a carousel, which
belongs to Carousels. Said on the button so nobody has to find out. */}
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5 text-xs hover:text-destructive"
disabled={busy}
onClick={() => void remove()}
title={`Delete this ${section.noun}. The carousel and its photographs stay.`}
>
<Trash2 className="size-3.5" />
Delete
</Button>
</div>
)}
{!selected ? (
<p className="text-xs text-muted-foreground">Choose a {section.noun}, or make one.</p>
) : !selected.carousel_id ? (
<p className="text-xs text-muted-foreground">
No carousel yet. Choose one and its photographs appear here.
</p>
) : photos === null ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Reading the carousel…
</p>
) : photos.length === 0 ? (
<p className="text-xs text-muted-foreground">
That carousel is empty. Add photographs to it in Carousels.
</p>
) : (
<>
<p className="mb-3 text-xs text-muted-foreground">
{photos.length} photographs, in the carousel&rsquo;s order &mdash; the first one leads.
Reorder them in Carousels.
</p>
<ul className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
{photos.map((photo) => (
<li key={photo.path}>
<div className="overflow-hidden rounded-md border border-border">
<img src={mediaThumbUrl(photo.path, 320)} alt="" className="aspect-square w-full object-cover" />
</div>
<p className="mt-1 truncate font-mono text-[10px] text-muted-foreground/70">{photo.name}</p>
</li>
))}
</ul>
</>
)}
</div>
{/* `selected` guarded: the picker writes to whatever is open, and there is nothing to write to
when nothing is. */}
{selected && (
<CarouselPicker
open={picking}
onClose={() => setPicking(false)}
onPick={(carouselId) => void chooseCarousel(carouselId)}
selected={selected.carousel_id}
/>
)}
</div>
);
};