A gallery could be created and deleted and not renamed, so fixing "Collections - 70´S" meant deleting it and choosing its photographs again — which would also have detached the collection pointing at it. Renaming writes the NAME and nothing else. Everything that references a gallery references its id — `collections.gallery_id`, and a gallery block's `content.galleryId` — and nothing renders through the slug, so a rename cannot break a reference today. The slug is left alone anyway, because it is an ADDRESS: it can end up in a link somebody sent, and on the day something does resolve by it, a label having quietly moved it would be a very hard bug to find. The row says so while you are typing rather than afterwards, because somebody about to change a name is exactly who wonders whether the address moves with it. A gallery whose slug is genuinely wrong is a different act with different consequences, and it should look like one. The API already takes them separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
import { Check, Images, Loader2, Pencil, Plus, Trash2, X } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { readableError } from '../errors';
|
|
import { useEditorSlot } from '../useEditorSlot';
|
|
import { GalleryItems } from './GalleryItems';
|
|
|
|
/**
|
|
* Galleries — named, ordered sets of media, reusable anywhere on the site.
|
|
*
|
|
* ── Why a gallery is not a list on a page ──
|
|
*
|
|
* The home page had one, as rows hanging off its own content. That works for exactly one. The moment a
|
|
* second page wants the same photographs in the same order, a list-per-page means choosing them again
|
|
* and keeping two copies in step by hand.
|
|
*
|
|
* ── The server owns the shape ──
|
|
*
|
|
* Every write answers with the whole gallery and this never patches its own state from what it sent.
|
|
* That matters most here: reordering renumbers everything, adding appends after whatever was there,
|
|
* and deleting closes a gap. A client keeping its own copy drifts on the first of those with no way to
|
|
* notice.
|
|
*/
|
|
|
|
type Item = { id: number; url: string; alt: string | null; sort_order: number };
|
|
type Gallery = { id: number; name: string; slug: string; items: Item[] };
|
|
|
|
export const GalleriesPanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [galleries, setGalleries] = useState<Gallery[] | null>(null);
|
|
const [openId, setOpenId] = useState<number | null>(null);
|
|
const [name, setName] = useState('');
|
|
/** Paths handed over by the context menu, waiting for a name. */
|
|
const [pending, setPending] = useState<string[]>([]);
|
|
/** The gallery being renamed, and the name being typed into it. Null when nobody is renaming. */
|
|
const [renaming, setRenaming] = useState<{ id: number; name: string } | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [params, setParams] = useSearchParams();
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/galleries');
|
|
setGalleries(response.data);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
setGalleries([]);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
/*
|
|
The selected gallery's contents, handed to the editor column.
|
|
|
|
Announced rather than rendered, because that panel is a SIBLING — see useEditorSlot. `onChanged`
|
|
reloads the list here, so the item count beside the name is never one behind what is in it.
|
|
|
|
The dependency list is everything the editor DISPLAYS: an element captures the props it was built
|
|
with, so one left out reads as a control that has stopped responding rather than as an error.
|
|
*/
|
|
const [, setEditor] = useEditorSlot();
|
|
const setEditorRef = useRef(setEditor);
|
|
setEditorRef.current = setEditor;
|
|
|
|
useEffect(() => {
|
|
setEditorRef.current(
|
|
openId === null ? null : <GalleryItems galleryId={openId} onChanged={() => void load()} />,
|
|
);
|
|
}, [openId, load]);
|
|
|
|
// Cleared on unmount, or the panel goes on offering a gallery over somebody else's screen.
|
|
useEffect(() => () => setEditorRef.current(null), []);
|
|
|
|
/*
|
|
Arriving from "Create Gallery" in the file browser.
|
|
|
|
The action puts the chosen paths in the URL and sends you here rather than opening its own dialog:
|
|
naming a gallery, seeing the slug it gets and watching the items land belongs on the screen that
|
|
owns galleries. Consumed once and removed from the URL — a `?from=` left behind would recreate the
|
|
gallery on every refresh, which somebody discovers as four copies.
|
|
*/
|
|
const arriving = params.get('from');
|
|
useEffect(() => {
|
|
if (arriving === null) return;
|
|
const urls = arriving.split('\n').filter(Boolean);
|
|
const suggested = params.get('create') ?? '';
|
|
setParams(
|
|
(current) => {
|
|
const next = new URLSearchParams(current);
|
|
next.delete('from');
|
|
next.delete('create');
|
|
return next;
|
|
},
|
|
{ replace: true },
|
|
);
|
|
// Pre-filled rather than created: the name is somebody's to choose and a folder name is only a
|
|
// suggestion. Nothing is written until Create.
|
|
setName(suggested);
|
|
setPending(urls);
|
|
}, [arriving, params, setParams]);
|
|
|
|
const send = async (label: string, call: () => Promise<unknown>) => {
|
|
setBusy(label);
|
|
setError(null);
|
|
try {
|
|
await call();
|
|
await load();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
/*
|
|
Renaming changes the NAME and leaves the slug exactly where it is.
|
|
|
|
Everything that points at a gallery points at its id — `collections.gallery_id`, and a gallery
|
|
block's `content.galleryId`. Nothing renders through the slug, so a rename cannot break a reference
|
|
today. It is left alone anyway, because a slug is an ADDRESS: it can end up in a link somebody
|
|
sent, and the day something does resolve by it, a label having quietly moved it would be a very
|
|
hard bug to find.
|
|
|
|
So the two are separable and this only writes one of them. A gallery whose slug is genuinely wrong
|
|
is a different act with different consequences, and it should look like one.
|
|
*/
|
|
const rename = () =>
|
|
send('rename', async () => {
|
|
if (!renaming?.name.trim()) return;
|
|
await clientRef.current.patch(`/fofos-admin/galleries/${renaming.id}`, { name: renaming.name.trim() });
|
|
setRenaming(null);
|
|
});
|
|
|
|
const create = () =>
|
|
send('create', async () => {
|
|
// `urls` in the same request, so creating from a selection is one action — splitting it would
|
|
// leave an empty gallery behind whenever the second call failed.
|
|
await clientRef.current.post('/fofos-admin/galleries', { name: name.trim(), urls: pending });
|
|
setName('');
|
|
setPending([]);
|
|
});
|
|
|
|
if (!galleries) return <div className="p-5 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
|
|
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
|
<h3 className="text-sm font-medium">Galleries</h3>
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
{/* What the context menu sent, so what is about to be created is not a mystery. */}
|
|
{pending.length > 0 && (
|
|
<p className="text-xs text-muted-foreground/80">
|
|
{pending.length} item{pending.length === 1 ? '' : 's'} ready — name it and press Create.
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Input
|
|
autoFocus={pending.length > 0}
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
placeholder="Name a new gallery"
|
|
className="min-w-0 flex-1 text-xs"
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && name.trim()) void create();
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-24 shrink-0 justify-center"
|
|
onClick={() => void create()}
|
|
disabled={busy !== null || !name.trim()}
|
|
>
|
|
{busy === 'create' ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
|
|
Create
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
{/*
|
|
The list, and only the list.
|
|
|
|
The contents of a gallery — its pictures, their order, their captions — are announced to the
|
|
editor column and drawn by `GalleryItems`, which is the same component a gallery BLOCK on a
|
|
page uses. Two copies of that list existed for a day and this is the one that went.
|
|
|
|
So this panel is what a master list should be: which galleries there are, making one, deleting
|
|
one. What is inside the selected one is a different question, answered beside it.
|
|
*/}
|
|
{galleries.map((gallery) => {
|
|
const open = openId === gallery.id;
|
|
return (
|
|
<section
|
|
key={gallery.id}
|
|
className={`flex items-center gap-3 rounded-lg border p-4 ${
|
|
open ? 'border-primary/60 bg-muted/40' : 'border-border'
|
|
}`}
|
|
>
|
|
{renaming?.id === gallery.id ? (
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<Images className="size-4 shrink-0" />
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<Input
|
|
autoFocus
|
|
value={renaming.name}
|
|
className="h-7 text-sm"
|
|
onChange={(event) => setRenaming({ id: gallery.id, name: event.target.value })}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && renaming.name.trim()) void rename();
|
|
if (event.key === 'Escape') setRenaming(null);
|
|
}}
|
|
/>
|
|
{/* Said while renaming, not afterwards: somebody about to change a name is exactly
|
|
who wonders whether the address moves with it. */}
|
|
<span className="block truncate font-mono text-xs text-muted-foreground/70">
|
|
{gallery.slug} · the address does not change
|
|
</span>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-7 shrink-0 p-0"
|
|
disabled={busy !== null || !renaming.name.trim()}
|
|
onClick={() => void rename()}
|
|
title="Save"
|
|
>
|
|
{busy === 'rename' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-7 shrink-0 p-0"
|
|
onClick={() => setRenaming(null)}
|
|
title="Cancel"
|
|
>
|
|
<X className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
onClick={() => setOpenId(open ? null : gallery.id)}
|
|
>
|
|
<Images className="size-4 shrink-0" />
|
|
<span className="min-w-0 space-y-1">
|
|
<span className="block truncate text-sm font-medium">{gallery.name}</span>
|
|
<span className="block truncate font-mono text-xs text-muted-foreground/70">
|
|
{gallery.slug} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{renaming?.id !== gallery.id && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() => setRenaming({ id: gallery.id, name: gallery.name })}
|
|
disabled={busy !== null}
|
|
title="Rename"
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() =>
|
|
void send(`gallery:${gallery.id}`, () =>
|
|
clientRef.current.delete(`/fofos-admin/galleries/${gallery.id}`),
|
|
)
|
|
}
|
|
disabled={busy !== null}
|
|
>
|
|
<Trash2 className="size-3.5 text-destructive" />
|
|
</Button>
|
|
</section>
|
|
);
|
|
})}
|
|
|
|
</div>
|
|
);
|
|
};
|