Files
pastilhasandClaude Opus 5 6e33e89e2c 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>
2026-09-04 18:06:06 +01:00

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 { CarouselItems } from './CarouselItems';
/**
* Carousels — named, ordered sets of media, reusable anywhere on the site.
*
* ── 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
* and keeping two copies in step by hand.
*
* ── The server owns the shape ──
*
* 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 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<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 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);
const [params, setParams] = useSearchParams();
const load = useCallback(async () => {
try {
const response = await clientRef.current.get<{ data: Carousel[] }>('/fofos-admin/carousels');
setCarousels(response.data);
} catch (failure) {
setError(readableError(failure));
setCarousels([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
/*
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.
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 : <CarouselItems carouselId={openId} onChanged={() => void load()} />,
);
}, [openId, load]);
// Cleared on unmount, or the panel goes on offering a carousel over somebody else's screen.
useEffect(() => () => setEditorRef.current(null), []);
/*
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 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
carousel 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 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 carousel 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/carousels/${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 carousel behind whenever the second call failed.
await clientRef.current.post('/fofos-admin/carousels', { name: name.trim(), urls: pending });
setName('');
setPending([]);
});
if (!carousels) 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">Carousels</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 carousel"
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 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((carousel) => {
const open = openId === carousel.id;
return (
<section
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 === 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">
<Input
autoFocus
value={renaming.name}
className="h-7 text-sm"
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);
}}
/>
{/* 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">
{carousel.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 : carousel.id)}
>
<Images className="size-4 shrink-0" />
<span className="min-w-0 space-y-1">
<span className="block truncate text-sm font-medium">{carousel.name}</span>
<span className="block truncate font-mono text-xs text-muted-foreground/70">
{carousel.slug} · {carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
</span>
</span>
</button>
)}
{renaming?.id !== carousel.id && (
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => setRenaming({ id: carousel.id, name: carousel.name })}
disabled={busy !== null}
title="Rename"
>
<Pencil className="size-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() =>
void send(`carousel:${carousel.id}`, () =>
clientRef.current.delete(`/fofos-admin/carousels/${carousel.id}`),
)
}
disabled={busy !== null}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</section>
);
})}
</div>
);
};