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:
@@ -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} />;
|
||||
|
||||
Reference in New Issue
Block a user