Two of these were live: PageBuilderPanel still compared kind === 'gallery' in two places, so a carousel block's editor and its Choose button were both dead branches. The type checker could not see them — both compare against string. The rest is copy somebody reads: "Change gallery", "Name a new gallery", "Reading the gallery", "Add to this gallery", Home's Gallery heading, and the "Create Gallery" file action, now "Carousel from folder". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
299 lines
11 KiB
TypeScript
299 lines
11 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 { thumbUrl } 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} onSaved={() => void load()} /> : null,
|
|
);
|
|
}, [selected, load, section]);
|
|
|
|
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’s order — 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={thumbUrl(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>
|
|
);
|
|
};
|