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:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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’s order — 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} />;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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'],
|
||||
};
|
||||
Reference in New Issue
Block a user