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:
2026-09-04 17:44:32 +01:00
co-authored by Claude Opus 5
parent 179507fb7b
commit 5e5c06e76e
10 changed files with 498 additions and 375 deletions
+2
View File
@@ -6,6 +6,7 @@ import { sectionFor, sectionOpen, type SectionKey } from './sections';
import { AdministrationPanel } from './AdministrationPanel';
import { MediaPanel } from './MediaPanel';
import { CollectionsPanel } from './sections/CollectionsPanel';
import { PiecesPanel } from './sections/PiecesPanel';
import { GalleriesPanel } from './sections/GalleriesPanel';
import { LayoutPanel } from './sections/LayoutPanel';
import { PageBuilderPanel } from './sections/PageBuilderPanel';
@@ -48,6 +49,7 @@ import { useReady } from './useReady';
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
layout: LayoutPanel,
collections: CollectionsPanel,
pieces: PiecesPanel,
galleries: GalleriesPanel,
messages: MessagesPanel,
administration: AdministrationPanel,
+25 -1
View File
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react';
import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft } from 'lucide-react';
import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft, Shapes } from 'lucide-react';
/**
* The sections of the fofos admin, in nav order.
@@ -39,6 +39,7 @@ import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft } from 'luci
export type SectionKey =
| 'administration'
| 'collections'
| 'pieces'
| 'galleries'
| 'media'
| 'messages'
@@ -147,6 +148,29 @@ export const SECTIONS: Section[] = [
requires: 'connection',
pinned: true,
},
{
/*
The Gallery's pieces.
Called Pieces, not Gallery. The section on the site is Gallery and its items are peças, and there
is already a `galleries` section here meaning something else entirely — a named set of media. Two
nav rows called Gallery and Galleries, meaning a list of objects and a list of photograph sets,
is a question somebody would have to answer every time they looked at it.
Below Collections because it is the same object one level flatter, and it shares its panel — see
sections/subjects.ts.
*/
key: 'pieces',
label: 'Pieces',
icon: Shapes,
hint: 'One object each, shown in the Gallery',
path: '/fofos-admin/pieces',
built: true,
// A piece is a ROW read through the site's admin API, so this is useless without the development
// server answering. Same as Collections above it.
requires: 'connection',
pinned: true,
},
{
key: 'galleries',
label: 'Galleries',
+22 -16
View File
@@ -30,11 +30,12 @@ export type ComponentKind =
| 'contact-form'
| 'partners'
| 'collections'
| 'collection-cover'
| 'collection-title'
| 'collection-description'
| 'collection-specs'
| 'collection-gallery';
| 'pieces'
| 'subject-cover'
| 'subject-title'
| 'subject-description'
| 'subject-specs'
| 'subject-gallery';
/*
The blocks that draw whatever the page is ABOUT.
@@ -43,11 +44,11 @@ export type ComponentKind =
block: one that is available, adds cleanly, and silently does nothing.
*/
export const SUBJECT_KINDS: ComponentKind[] = [
'collection-cover',
'collection-title',
'collection-description',
'collection-specs',
'collection-gallery',
'subject-cover',
'subject-title',
'subject-description',
'subject-specs',
'subject-gallery',
];
type Choice = {
@@ -91,18 +92,23 @@ export const CHOICES: Choice[] = [
{ kind: 'contact-form', label: 'Contact form', hint: 'The site\'s form, as it is', Icon: Mail },
{ kind: 'partners', label: 'Partners', hint: 'The partner logos, from Site', Icon: Handshake },
{ kind: 'collections', label: 'Collections', hint: 'Every collection, as a grid', Icon: LayoutGrid },
{ kind: 'pieces', label: 'Pieces', hint: 'Every gallery piece, as a grid', Icon: LayoutGrid },
/*
The four that draw THIS collection, on the template.
The five that draw whatever the page is ABOUT, on a template.
They hold nothing — not even a reference. The subject is the URL, so one block draws a different
collection on every request, and that is what lets one arrangement serve all of them.
Called `subject-` rather than `collection-`: the same five draw a gallery piece, and the labels say
"it" for that reason. What differs between the sections is a path and a few spec labels, and both
travel with the subject.
*/
{ kind: 'collection-cover', label: 'Its lead photograph', hint: 'The first in its gallery, large', Icon: Image },
{ kind: 'collection-title', label: 'Its name', hint: 'The collection this page is about', Icon: Heading },
{ kind: 'collection-description', label: 'Its description', hint: 'What was written about it', Icon: Type },
{ kind: 'collection-specs', label: 'Its specs', hint: 'Measurement, material, colours, delivery', Icon: Ruler },
{ kind: 'collection-gallery', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images },
{ kind: 'subject-cover', label: 'Its lead photograph', hint: 'The first in its gallery, large', Icon: Image },
{ kind: 'subject-title', label: 'Its name', hint: 'What this page is about', Icon: Heading },
{ kind: 'subject-description', label: 'Its description', hint: 'What was written about it', Icon: Type },
{ kind: 'subject-specs', label: 'Its specs', hint: 'Measurement, material, colours, and the rest', Icon: Ruler },
{ kind: 'subject-gallery', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images },
];
export const AddComponentDialog = ({
+4 -300
View File
@@ -1,301 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Boxes, 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 { CollectionFields } from './CollectionFields';
import type { Collection, CollectionPhoto } from './collections';
import { SubjectPanel } from './SubjectPanel';
import { COLLECTIONS } from './subjects';
/**
* Collections — one folder of photographs each, and what is said about it.
*
* ── The list is the DISK ──
*
* Which collections exist is a question about `originals/COLLECTIONS/`, not about a table. The API
* registers every folder as it reads them, so a collection appears here the moment its folder does and
* there is no "create" button: making one means making a folder, in Media or in Finder, and both work.
*
* Deleting is the same in reverse and is deliberately not offered. A button here would either remove a
* row and leave the photographs — so the collection reappears on the next read — or delete somebody's
* pictures. Neither is what a Delete button in an admin should mean.
*
* ── The grid is for choosing the COVER ──
*
* The photographs are already ordered by their filenames and the gallery is the folder, so there is
* nothing to reorder and nothing to add. The one decision is which picture leads, and that is a click.
*/
export const CollectionsPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [collections, setCollections] = useState<Collection[] | null>(null);
const [photos, setPhotos] = useState<CollectionPhoto[] | 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('collection')) || null;
const selected = collections?.find((one) => one.id === selectedId) ?? null;
const load = useCallback(async () => {
try {
const response = await clientRef.current.get<{ data: Collection[] }>('/fofos-admin/collections');
setCollections(response.data);
} catch (failure) {
setError(readableError(failure));
setCollections([]);
}
}, []);
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 a collection 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 (!collections || !selectedId) return;
if (collections.some((one) => one.id === selectedId)) return;
setParams(
(current) => {
const next = new URLSearchParams(current);
next.delete('collection');
return next;
},
{ replace: true },
);
}, [collections, selectedId, setParams]);
// 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: CollectionPhoto[] }>(`/fofos-admin/collections/${selectedId}/media`)
.then((response) => alive && setPhotos(response.data))
.catch((failure: unknown) => alive && setError(readableError(failure)));
return () => {
alive = false;
};
}, [selectedId, selected]);
/*
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 ? <CollectionFields collection={selected} onSaved={() => void load()} /> : null,
);
}, [selected, load]);
useEffect(() => () => setEditorRef.current(null), []);
const select = (id: number) =>
setParams(
(current) => {
const next = new URLSearchParams(current);
next.set('collection', 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: Collection }>('/fofos-admin/collections', {
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(`/fofos-admin/collections/${selected.id}`);
setParams(
(current) => {
const next = new URLSearchParams(current);
next.delete('collection');
return next;
},
{ replace: true },
);
});
const chooseGallery = (galleryId: number) =>
send(async () => {
if (!selected) return;
setPicking(false);
await clientRef.current.patch(`/fofos-admin/collections/${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">
{collections === null ? (
<li className="p-4 text-xs text-muted-foreground">Reading the folders…</li>
) : collections.length === 0 ? (
<li className="p-4 text-xs text-muted-foreground">
No collections yet.
</li>
) : (
collections.map((collection) => (
<li key={collection.id}>
<button
type="button"
onClick={() => select(collection.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 ${
collection.id === selectedId ? 'bg-muted/60' : ''
}`}
>
<Boxes className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{collection.title}</span>
{/* The count, because a collection with no photographs looks identical to one nobody
has opened, and two of these are genuinely empty. */}
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{collection.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 collection"
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 COLLECTION, 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 collection. The gallery and its photographs stay."
>
<Trash2 className="size-3.5" />
Delete
</Button>
</div>
)}
{!selected ? (
<p className="text-xs text-muted-foreground">Choose a collection, 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&rsquo;s order &mdash; the first one leads. Reorder
them in Galleries.
</p>
{/* Not buttons. Nothing here is a decision — the order is the gallery's and so is which one
leads. Making them look pressable would promise a control that lives elsewhere. */}
<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>
);
};
/** Collections. The panel is shared with the Gallery's pieces — see subjects.ts. */
export const CollectionsPanel = () => <SubjectPanel section={COLLECTIONS} />;
+13 -11
View File
@@ -23,11 +23,12 @@ const LABELS: Record<string, string> = {
'contact-form': 'Contact form',
partners: 'Partners',
collections: 'Collections',
'collection-cover': 'Lead photograph',
'collection-title': 'Name',
'collection-description': 'Description',
'collection-specs': 'Specs',
'collection-gallery': 'Photographs',
pieces: 'Pieces',
'subject-cover': 'Lead photograph',
'subject-title': 'Name',
'subject-description': 'Description',
'subject-specs': 'Specs',
'subject-gallery': 'Photographs',
};
/*
@@ -40,12 +41,13 @@ const PLACES_ONLY: string[] = [
'contact-form',
'partners',
'collections',
// The subject blocks hold nothing either: what they draw is the collection the URL names.
'collection-cover',
'collection-title',
'collection-description',
'collection-specs',
'collection-gallery',
'pieces',
// The subject blocks hold nothing either: what they draw is the row the URL names.
'subject-cover',
'subject-title',
'subject-description',
'subject-specs',
'subject-gallery',
];
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextEditor } from './FreeTextEditor';
+5
View File
@@ -0,0 +1,5 @@
import { SubjectPanel } from './SubjectPanel';
import { PIECES } from './subjects';
/** The Gallery's pieces. The panel is shared with Collections — see subjects.ts. */
export const PiecesPanel = () => <SubjectPanel section={PIECES} />;
@@ -3,16 +3,15 @@ import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useAutosave } from '../useAutosave';
import type { Collection } from './collections';
import type { SubjectRecord, SubjectSection } from './subjects';
/**
* What somebody says about a collection, in the editor column.
* What somebody says about a collection or a piece, in the editor column.
*
* ── Everything here is what a FOLDER cannot tell you ──
* ── One form, two sections ──
*
* Which photographs are in a collection is a question about `originals/COLLECTIONS/<name>/`, answered
* by reading it. This is the other half: the name it goes by, the address it lives at, and the specs
* from the client's sitemap. None of it can be derived, so all of it is typed.
* The fields below the description come from the section — see subjects.ts. Everything above them is
* shared because it is the same in both: what it is called, and where it lives.
*
* The gallery is not here. It is chosen beside the photographs it puts on screen, in the panel, where
* the consequence of choosing it is visible — a dropdown in a column of text fields would be the one
@@ -24,42 +23,34 @@ import type { Collection } from './collections';
* eight requests for somebody filling in a form, and the last one to land would win in an order nobody
* controls.
*/
/** The fields, in the order the client's sitemap lists them. */
const SPECS: [keyof Collection, string, string][] = [
['descriptive_text', 'Descriptive text', 'What this collection is'],
['measurement', 'Measurement', '45cm x 45cm'],
['material', 'Raw material', 'Acrylic yarn'],
['colors', 'Colours', ''],
['delivery_time', 'Delivery time', '4-6 weeks'],
];
export const CollectionFields = ({
collection,
export const SubjectFields = <T extends SubjectRecord>({
section,
record,
onSaved,
}: {
collection: Collection;
section: SubjectSection<T>;
record: T;
onSaved: () => void;
}) => {
const client = useClient();
const [draft, setDraft] = useState<Partial<Collection>>({});
const [draft, setDraft] = useState<Partial<Record<string, string>>>({});
// Keyed on the ID, not the values: keyed on the values it would fight the typing, pulling every
// keystroke back to the last saved one until the save landed.
useEffect(() => setDraft({}), [collection.id]);
useEffect(() => setDraft({}), [record.id]);
const value = (key: keyof Collection): string =>
(draft[key] as string | null | undefined) ?? (collection[key] as string | null) ?? '';
const value = (key: string): string =>
draft[key] ?? ((record as Record<string, unknown>)[key] as string | null) ?? '';
const dirty = Object.keys(draft).length > 0;
useAutosave(dirty ? JSON.stringify(draft) : null, false, async () => {
await client.patch(`/fofos-admin/collections/${collection.id}`, draft);
await client.patch(`${section.endpoint}/${record.id}`, draft);
setDraft({});
onSaved();
});
const set = (key: keyof Collection) => (next: string) => setDraft((was) => ({ ...was, [key]: next }));
const set = (key: string) => (next: string) => setDraft((was) => ({ ...was, [key]: next }));
return (
<div className="space-y-4">
@@ -71,7 +62,9 @@ export const CollectionFields = ({
<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)} />
<p className="font-mono text-[11px] text-muted-foreground/70">/collections/{value('slug')}</p>
<p className="font-mono text-[11px] text-muted-foreground/70">
{section.base}/{value('slug')}
</p>
</div>
<div className="space-y-1.5">
@@ -83,10 +76,10 @@ export const CollectionFields = ({
/>
</div>
{SPECS.map(([key, label, placeholder]) => (
{section.specs.map(([key, label, placeholder]) => (
<div key={key} className="space-y-1.5">
<label className="text-xs text-muted-foreground">{label}</label>
{key === 'descriptive_text' ? (
{section.long?.includes(key) ? (
<Textarea
rows={3}
placeholder={placeholder}
@@ -102,7 +95,6 @@ export const CollectionFields = ({
)}
</div>
))}
</div>
);
};
+298
View File
@@ -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&rsquo;s order &mdash; 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>
);
};
-18
View File
@@ -1,18 +0,0 @@
/** A collection, as the site's admin API serialises one. Snake case: it is the wire, not our shape. */
export type Collection = {
id: number;
/** The gallery holding its photographs, or null while nobody has chosen one. */
gallery_id: number | null;
title: string;
slug: string | null;
description: string | null;
descriptive_text: string | null;
measurement: string | null;
material: string | null;
colors: string | null;
delivery_time: string | null;
sort_order: number;
photo_count: number;
};
export type CollectionPhoto = { path: string; name: string };
+108
View File
@@ -0,0 +1,108 @@
import type { LucideIcon } from 'lucide-react';
import { Boxes, Shapes } from 'lucide-react';
/**
* The two sections whose rows are drawn through a template — collections, and gallery pieces.
*
* ── Why one shape ──
*
* They are the same object. A title, an address, some words, a gallery, and a table of properties;
* the sections differ in what they GROUP, not in how one is edited. Copying the panel would be three
* hundred lines duplicated for an icon and a noun, and the copies would drift the first time one of
* them got a fix.
*
* The site draws them through one set of blocks for exactly the same reason — see
* `src/apps/web/components/subject.ts` in the website.
*
* ── What a section still owns ──
*
* Its endpoint, the word it puts in the URL, and its SPEC FIELDS. That last one is the real
* difference: a collection ends with a delivery time; a piece ends with the sitemap's NOTAS*,
* production time and shipping conditions. Both are just rows in a form, and naming them here is what
* keeps the form from having to know which section it is drawing.
*/
/** What every subject row has. Snake case: it is the wire, not our shape. */
export type SubjectRecord = {
id: number;
/** The gallery holding its media, or null while nobody has chosen one. */
gallery_id: number | null;
title: string;
slug: string | null;
description: string | null;
sort_order: number;
photo_count: number;
};
export type Collection = SubjectRecord & {
descriptive_text: string | null;
measurement: string | null;
material: string | null;
colors: string | null;
delivery_time: string | null;
};
export type Piece = SubjectRecord & {
descriptive_text: string | null;
measurement: string | null;
material: string | null;
colors: string | null;
production_notes: string | null;
shipping_notes: string | null;
};
export type SubjectPhoto = { path: string; name: string };
export type SubjectSection<T extends SubjectRecord> = {
/** The query key the selection lives under — `?collection=3`. See docs/navigation-audit.md. */
param: string;
/** Where the rows are, relative to the plugin's mount. */
endpoint: string;
/** The site path they live at, for the address hint under the slug field. */
base: string;
icon: LucideIcon;
/** Lower case, mid-sentence: "Choose a collection, or make one." */
noun: string;
/** Key, label, placeholder — in the order the client's sitemap lists them. */
specs: [Extract<keyof T, string>, string, string][];
/** Which of those are long enough to want a textarea. */
long?: Extract<keyof T, string>[];
};
export const COLLECTIONS: SubjectSection<Collection> = {
param: 'collection',
endpoint: '/fofos-admin/collections',
base: '/collections',
icon: Boxes,
noun: 'collection',
specs: [
['descriptive_text', 'Descriptive text', 'What this collection is'],
['measurement', 'Measurement', '45cm x 45cm'],
['material', 'Raw material', 'Acrylic yarn'],
['colors', 'Colours', ''],
['delivery_time', 'Delivery time', '4-6 weeks'],
],
long: ['descriptive_text'],
};
export const PIECES: SubjectSection<Piece> = {
param: 'piece',
endpoint: '/fofos-admin/pieces',
base: '/gallery',
icon: Shapes,
noun: 'piece',
/*
The last two are the sitemap's NOTAS* — conditions of buying the object rather than properties of
it. Two more fields here, because that is honestly all the difference amounts to; the template is
where they are drawn apart.
*/
specs: [
['descriptive_text', 'Descriptive text', 'What this piece is'],
['measurement', 'Measurement', '45cm x 45cm'],
['material', 'Raw material', 'Acrylic yarn'],
['colors', 'Colours', ''],
['production_notes', 'Production time', 'Made to order, 4-6 weeks'],
['shipping_notes', 'Shipping', 'Ships worldwide, tracked'],
],
long: ['descriptive_text'],
};