rename a gallery, and leave its address alone

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>
This commit is contained in:
2026-09-04 14:23:34 +01:00
co-authored by Claude Opus 5
parent d459c53c3f
commit bc494fb57a
+77 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { Images, Loader2, Plus, Trash2 } from 'lucide-react'; import { Check, Images, Loader2, Pencil, Plus, Trash2, X } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -38,6 +38,8 @@ export const GalleriesPanel = () => {
const [name, setName] = useState(''); const [name, setName] = useState('');
/** Paths handed over by the context menu, waiting for a name. */ /** Paths handed over by the context menu, waiting for a name. */
const [pending, setPending] = useState<string[]>([]); 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 [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
@@ -119,6 +121,25 @@ export const GalleriesPanel = () => {
} }
}; };
/*
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 = () => const create = () =>
send('create', async () => { send('create', async () => {
// `urls` in the same request, so creating from a selection is one action — splitting it would // `urls` in the same request, so creating from a selection is one action — splitting it would
@@ -187,6 +208,47 @@ export const GalleriesPanel = () => {
open ? 'border-primary/60 bg-muted/40' : 'border-border' 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 <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left" className="flex min-w-0 flex-1 items-center gap-2 text-left"
@@ -200,6 +262,20 @@ export const GalleriesPanel = () => {
</span> </span>
</span> </span>
</button> </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 <Button
variant="ghost" variant="ghost"