a place to manage each collection

Collections, above Galleries and Media, where the other things made of media already are.

There is no Create button and no Delete, and that is the design rather than an omission. Which
collections exist is a question about `originals/COLLECTIONS/`, so making one means making a folder —
in Media or in Finder, and both work. A Delete here would either drop the row and leave the
photographs, so the collection reappears on the next read, or delete somebody's pictures. Neither is
what Delete should mean in an admin.

The grid is for choosing the MAIN photo and nothing else. The photographs are already in filename
order and the gallery is the folder, so there is nothing to reorder and nothing to add — the one
decision is which picture leads. Pressing the chosen one again clears it, which is how "no choice, use
the first" stays reachable without a second control that only does that.

The badge says Main rather than Cover because it has to be true of the first photograph, which leads
by default, as well as of one somebody picked.

The fields are the other half — everything a directory cannot tell you: the name it goes by, the
address, and the specs from the client's sitemap. `folder` is shown and not editable, and the API
refuses to write it: it is an address, and renaming one moves files. Shown anyway, because it is the
only thing that says WHICH folder a collection is.

One draft, one save. Per-field writes would be eight requests for somebody filling in a form, and the
last to land would win in an order nobody controls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:03:54 +01:00
co-authored by Claude Opus 5
parent dde7de67aa
commit 60eb6ecfc9
5 changed files with 364 additions and 2 deletions
+2
View File
@@ -5,6 +5,7 @@ import { DEFAULT_SECTION } from './sections';
import { sectionFor, sectionOpen, type SectionKey } from './sections'; import { sectionFor, sectionOpen, type SectionKey } from './sections';
import { AdministrationPanel } from './AdministrationPanel'; import { AdministrationPanel } from './AdministrationPanel';
import { MediaPanel } from './MediaPanel'; import { MediaPanel } from './MediaPanel';
import { CollectionsPanel } from './sections/CollectionsPanel';
import { GalleriesPanel } from './sections/GalleriesPanel'; import { GalleriesPanel } from './sections/GalleriesPanel';
import { LayoutPanel } from './sections/LayoutPanel'; import { LayoutPanel } from './sections/LayoutPanel';
import { PageBuilderPanel } from './sections/PageBuilderPanel'; import { PageBuilderPanel } from './sections/PageBuilderPanel';
@@ -46,6 +47,7 @@ import { useReady } from './useReady';
*/ */
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = { const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
layout: LayoutPanel, layout: LayoutPanel,
collections: CollectionsPanel,
galleries: GalleriesPanel, galleries: GalleriesPanel,
messages: MessagesPanel, messages: MessagesPanel,
administration: AdministrationPanel, administration: AdministrationPanel,
+25 -2
View File
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Images, Mail, Cog, FolderOpen, PanelsTopLeft, Files } from 'lucide-react'; import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft } from 'lucide-react';
/** /**
* The sections of the fofos admin, in nav order. * The sections of the fofos admin, in nav order.
@@ -36,7 +36,14 @@ import { Images, Mail, Cog, FolderOpen, PanelsTopLeft, Files } from 'lucide-reac
* the nav and what actually renders cannot disagree. * the nav and what actually renders cannot disagree.
*/ */
export type SectionKey = 'administration' | 'galleries' | 'media' | 'messages' | 'layout' | 'pages'; export type SectionKey =
| 'administration'
| 'collections'
| 'galleries'
| 'media'
| 'messages'
| 'layout'
| 'pages';
export type Section = { export type Section = {
key: SectionKey; key: SectionKey;
@@ -125,6 +132,22 @@ export const SECTIONS: Section[] = [
this section is useless without the development server answering, and opening it there would be this section is useless without the development server answering, and opening it there would be
a screen where every action fails. a screen where every action fails.
*/ */
key: 'collections',
label: 'Collections',
icon: Boxes,
hint: 'A folder of photographs, and what is said about it',
path: '/fofos-admin/collections',
built: true,
/*
Same reason as Galleries: a collection is a ROW read through the site's admin API, so this is
useless without the development server answering. What is different is that the row is only half
of it — which collections EXIST is a question about `originals/COLLECTIONS/`, and the folders are
registered as this list is read.
*/
requires: 'connection',
pinned: true,
},
{
key: 'galleries', key: 'galleries',
label: 'Galleries', label: 'Galleries',
icon: Images, icon: Images,
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react';
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';
/**
* What somebody says about a collection, in the editor column.
*
* ── Everything here is what a FOLDER cannot tell you ──
*
* 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.
*
* `folder` is shown and not editable. It is an address — renaming it moves files — and the API refuses
* to write it for the same reason. Showing it anyway matters: it is the only thing that says WHICH
* folder a collection is, and somebody looking at two similarly named ones needs to be able to tell.
*
* ── One draft, one save ──
*
* Every field writes into one draft object and `useAutosave` sends it whole. Per-field saves would be
* 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,
onSaved,
}: {
collection: Collection;
onSaved: () => void;
}) => {
const client = useClient();
const [draft, setDraft] = useState<Partial<Collection>>({});
// 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]);
const value = (key: keyof Collection): string =>
(draft[key] as string | null | undefined) ?? (collection[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);
setDraft({});
onSaved();
});
const set = (key: keyof Collection) => (next: string) => setDraft((was) => ({ ...was, [key]: next }));
return (
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Name</label>
<Input value={value('title')} onChange={(event) => set('title')(event.target.value)} />
</div>
<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>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Description</label>
<Textarea
rows={4}
value={value('description')}
onChange={(event) => set('description')(event.target.value)}
/>
</div>
{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' ? (
<Textarea
rows={3}
placeholder={placeholder}
value={value(key)}
onChange={(event) => set(key)(event.target.value)}
/>
) : (
<Input
placeholder={placeholder}
value={value(key)}
onChange={(event) => set(key)(event.target.value)}
/>
)}
</div>
))}
{/* Read-only, and shown rather than hidden: it is the only thing that says which folder this is,
and the API refuses to write it because renaming one moves files on disk. */}
<div className="space-y-1.5 border-t border-border pt-4">
<label className="text-xs text-muted-foreground">Folder</label>
<p className="font-mono text-[11px] break-all text-muted-foreground/70">{collection.folder ?? '—'}</p>
</div>
</div>
);
};
+204
View File
@@ -0,0 +1,204 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Boxes, Check, Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useEditorSlot } from '../useEditorSlot';
import { thumbUrl } from '../thumbnail';
import { CollectionFields } from './CollectionFields';
import type { Collection, CollectionPhoto } from './collections';
/**
* 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);
// 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]);
// The folder's contents, 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) {
setPhotos(null);
return;
}
let alive = true;
setPhotos(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]);
/*
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 chooseCover = async (name: string) => {
if (!selected) return;
setBusy(true);
setError(null);
try {
// Pressing the one already chosen clears it, which is how "no choice, use the first" is reachable
// again without a second control that only does that.
const next = selected.cover === name ? null : name;
await clientRef.current.patch(`/fofos-admin/collections/${selected.id}`, { cover: next });
await load();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const select = (id: number) =>
setParams(
(current) => {
const next = new URLSearchParams(current);
next.set('collection', String(id));
return next;
},
{ replace: true },
);
return (
<div className="flex size-full min-h-0">
<ul className="w-64 shrink-0 overflow-y-auto border-r border-border">
{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">
Nothing under <span className="font-mono">originals/COLLECTIONS</span> 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>
<div className="min-w-0 flex-1 overflow-y-auto p-4">
{error && <p className="mb-3 text-xs text-destructive">{error}</p>}
{!selected ? (
<p className="text-xs text-muted-foreground">Choose a collection.</p>
) : photos === null ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> Reading {selected.folder}…
</p>
) : photos.length === 0 ? (
<p className="text-xs text-muted-foreground">
That folder is empty. Put photographs in <span className="font-mono">{selected.folder}</span>.
</p>
) : (
<>
<p className="mb-3 text-xs text-muted-foreground">
{photos.length} photographs, in filename order. Click one to make it the main photo.
</p>
<ul className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
{photos.map((photo) => {
const isCover = selected.cover ? photo.name === selected.cover : photo === photos[0];
return (
<li key={photo.path}>
<button
type="button"
disabled={busy}
onClick={() => void chooseCover(photo.name)}
className={`relative block w-full overflow-hidden rounded-md border transition-colors ${
isCover ? 'border-primary ring-1 ring-primary' : 'border-border hover:border-primary/50'
}`}
>
<img src={thumbUrl(photo.path, 320)} alt="" className="aspect-square w-full object-cover" />
{isCover && (
<span className="absolute top-1.5 right-1.5 flex items-center gap-1 rounded bg-primary px-1.5 py-0.5 text-[10px] text-primary-foreground shadow">
<Check className="size-2.5" />
{/* "Main" rather than "Cover": the first one is the main photo by default, so
the badge has to be true of a picture nobody chose as well as one somebody
did. */}
Main
</span>
)}
</button>
<p className="mt-1 truncate font-mono text-[10px] text-muted-foreground/70">{photo.name}</p>
</li>
);
})}
</ul>
</>
)}
</div>
</div>
);
};
+19
View File
@@ -0,0 +1,19 @@
/** A collection, as the site's admin API serialises one. Snake case: it is the wire, not our shape. */
export type Collection = {
id: number;
folder: string | null;
title: string;
slug: string | null;
/** A FILENAME within the folder, not a path — see the site's schema. */
cover: 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 };