finish the carousel rename in the panels

Two of these were live: PageBuilderPanel still compared kind === 'gallery' in
two places, so a carousel block's editor and its Choose button were both dead
branches. The type checker could not see them — both compare against string.

The rest is copy somebody reads: "Change gallery", "Name a new gallery",
"Reading the gallery", "Add to this gallery", Home's Gallery heading, and the
"Create Gallery" file action, now "Carousel from folder".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:06:06 +01:00
co-authored by Claude Opus 5
parent 15042ccc76
commit 6e33e89e2c
15 changed files with 104 additions and 104 deletions
+30 -30
View File
@@ -11,7 +11,7 @@ import { CarouselItems } from './CarouselItems';
/**
* Carousels — named, ordered sets of media, reusable anywhere on the site.
*
* ── Why a gallery is not a list on a page ──
* ── Why a carousel 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
@@ -19,26 +19,26 @@ import { CarouselItems } from './CarouselItems';
*
* ── The server owns the shape ──
*
* Every write answers with the whole gallery and this never patches its own state from what it sent.
* Every write answers with the whole carousel 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[] };
type Carousel = { id: number; name: string; slug: string; items: Item[] };
export const CarouselsPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [carousels, setCarousels] = useState<Gallery[] | null>(null);
const [carousels, setCarousels] = useState<Carousel[] | 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. */
/** The carousel 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);
@@ -46,7 +46,7 @@ export const CarouselsPanel = () => {
const load = useCallback(async () => {
try {
const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/carousels');
const response = await clientRef.current.get<{ data: Carousel[] }>('/fofos-admin/carousels');
setCarousels(response.data);
} catch (failure) {
setError(readableError(failure));
@@ -59,7 +59,7 @@ export const CarouselsPanel = () => {
}, [load]);
/*
The selected gallery's contents, handed to the editor column.
The selected carousel'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.
@@ -77,16 +77,16 @@ export const CarouselsPanel = () => {
);
}, [openId, load]);
// Cleared on unmount, or the panel goes on offering a gallery over somebody else's screen.
// Cleared on unmount, or the panel goes on offering a carousel over somebody else's screen.
useEffect(() => () => setEditorRef.current(null), []);
/*
Arriving from "Create Gallery" in the file browser.
Arriving from "Carousel from folder" 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
naming a carousel, seeing the slug it gets and watching the items land belongs on the screen that
owns carousels. Consumed once and removed from the URL — a `?from=` left behind would recreate the
gallery on every refresh, which somebody discovers as four copies.
carousel on every refresh, which somebody discovers as four copies.
*/
const arriving = params.get('from');
useEffect(() => {
@@ -124,13 +124,13 @@ export const CarouselsPanel = () => {
/*
Renaming changes the NAME and leaves the slug exactly where it is.
Everything that points at a gallery points at its id — `collections.carousel_id`, and a gallery
Everything that points at a carousel points at its id — `collections.carousel_id`, and a carousel
block's `content.carouselId`. 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
So the two are separable and this only writes one of them. A carousel whose slug is genuinely wrong
is a different act with different consequences, and it should look like one.
*/
const rename = () =>
@@ -143,7 +143,7 @@ export const CarouselsPanel = () => {
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.
// leave an empty carousel behind whenever the second call failed.
await clientRef.current.post('/fofos-admin/carousels', { name: name.trim(), urls: pending });
setName('');
setPending([]);
@@ -170,7 +170,7 @@ export const CarouselsPanel = () => {
autoFocus={pending.length > 0}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Name a new gallery"
placeholder="Name a new carousel"
className="min-w-0 flex-1 text-xs"
onKeyDown={(event) => {
if (event.key === 'Enter' && name.trim()) void create();
@@ -192,23 +192,23 @@ export const CarouselsPanel = () => {
{/*
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 `CarouselItems`, which is the same component a gallery BLOCK on a
The contents of a carousel — its pictures, their order, their captions — are announced to the
editor column and drawn by `CarouselItems`, which is the same component a carousel 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 carousels there are, making one, deleting
one. What is inside the selected one is a different question, answered beside it.
*/}
{carousels.map((gallery) => {
const open = openId === gallery.id;
{carousels.map((carousel) => {
const open = openId === carousel.id;
return (
<section
key={gallery.id}
key={carousel.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 ? (
{renaming?.id === carousel.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">
@@ -216,7 +216,7 @@ export const CarouselsPanel = () => {
autoFocus
value={renaming.name}
className="h-7 text-sm"
onChange={(event) => setRenaming({ id: gallery.id, name: event.target.value })}
onChange={(event) => setRenaming({ id: carousel.id, name: event.target.value })}
onKeyDown={(event) => {
if (event.key === 'Enter' && renaming.name.trim()) void rename();
if (event.key === 'Escape') setRenaming(null);
@@ -225,7 +225,7 @@ export const CarouselsPanel = () => {
{/* 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
{carousel.slug} · the address does not change
</span>
</div>
<Button
@@ -252,24 +252,24 @@ export const CarouselsPanel = () => {
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => setOpenId(open ? null : gallery.id)}
onClick={() => setOpenId(open ? null : carousel.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 text-sm font-medium">{carousel.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'}
{carousel.slug} · {carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
</span>
</span>
</button>
)}
{renaming?.id !== gallery.id && (
{renaming?.id !== carousel.id && (
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => setRenaming({ id: gallery.id, name: gallery.name })}
onClick={() => setRenaming({ id: carousel.id, name: carousel.name })}
disabled={busy !== null}
title="Rename"
>
@@ -282,8 +282,8 @@ export const CarouselsPanel = () => {
size="sm"
className="shrink-0"
onClick={() =>
void send(`gallery:${gallery.id}`, () =>
clientRef.current.delete(`/fofos-admin/carousels/${gallery.id}`),
void send(`carousel:${carousel.id}`, () =>
clientRef.current.delete(`/fofos-admin/carousels/${carousel.id}`),
)
}
disabled={busy !== null}