Pieces: the Gallery, as a section like Collections
The Gallery is a flat list of peças, each with a title, a gallery of mixed media, and the specs from the client's sitemap — measurement, raw material, colours, and NOTAS: production time and shipping conditions. That is a collection one level flatter, so the panel is shared rather than copied. SubjectPanel and SubjectFields take a section config (subjects.ts); CollectionsPanel and PiecesPanel are one line each. The five template blocks are renamed collection-* to subject-*. They never read anything collection-specific, and copying them per section would have meant twenty kinds by the time Drops and Collabs land. Called Pieces in the nav, not Gallery — there is already a Galleries section meaning a named set of media, and two rows called Gallery and Galleries is a question somebody has to answer every time they look at it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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 { GalleryPicker } from '../GalleryPicker';
|
||||
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 gallery, 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
|
||||
* Galleries.
|
||||
*/
|
||||
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 gallery'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 chooseGallery = (galleryId: number) =>
|
||||
send(async () => {
|
||||
if (!selected) return;
|
||||
setPicking(false);
|
||||
await clientRef.current.patch(`${section.endpoint}/${selected.id}`, { gallery_id: galleryId });
|
||||
});
|
||||
|
||||
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.gallery_id ? 'Change gallery' : 'Choose a gallery'}
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1" />
|
||||
{/* Deleting removes the ROW, never the photographs — they belong to a gallery, which
|
||||
belongs to Galleries. 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 gallery 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.gallery_id ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No gallery 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 gallery…
|
||||
</p>
|
||||
) : photos.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
That gallery is empty. Add photographs to it in Galleries.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{photos.length} photographs, in the gallery’s order — the first one leads.
|
||||
Reorder them in Galleries.
|
||||
</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 && (
|
||||
<GalleryPicker
|
||||
open={picking}
|
||||
onClose={() => setPicking(false)}
|
||||
onPick={(galleryId) => void chooseGallery(galleryId)}
|
||||
selected={selected.gallery_id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user