A collection named its own main picture. A gallery is already ORDERED — somebody drags it into the sequence they want — so "which one leads" was being answered twice, in two screens, by two mechanisms that could disagree. Reordering a gallery and then wondering why the card had not changed was a bug waiting to be filed. So the grid here decides nothing at all. It is what the collection SHOWS, in the gallery's order, and the pictures are no longer buttons: making them look pressable would promise a control that lives in another screen. Adding, reordering and framing all belong to Galleries, and now so does this. The `cover` column is gone from both databases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* 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>
|
|
);
|
|
};
|