The collection id lives in the URL, so it outlives the row. Delete one with it open — or have somebody else delete it — and the address still asks for it, which made the media fetch 404 and put "Not found" across a panel that was otherwise working perfectly. An error about the URL, phrased as an error about the server. Now a selection naming nothing is quietly removed from the URL. There is nothing anybody could do about it and the correct state is simply "nothing selected". The error is also cleared when the selection moves. It was only cleared on the next failure, so a message from the last collection stayed up over the new one and read as this one having failed too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
import { Boxes, Check, 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';
|
|
|
|
/**
|
|
* 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 });
|
|
});
|
|
|
|
const chooseCover = (path: string) =>
|
|
send(async () => {
|
|
if (!selected) return;
|
|
// 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.
|
|
await clientRef.current.patch(`/fofos-admin/collections/${selected.id}`, {
|
|
cover: selected.cover === path ? null : path,
|
|
});
|
|
});
|
|
|
|
|
|
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. 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.path === selected.cover : photo === photos[0];
|
|
return (
|
|
<li key={photo.path}>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void chooseCover(photo.path)}
|
|
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>
|
|
{/* `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>
|
|
);
|
|
};
|