a gallery is a carousel

It always rendered as one — data-carousel, a snap track, two arrows, driven by
carousel.client.js. The entity was the last thing still calling it something
else, and that collided with the site's own Gallery section: two nav rows
reading Gallery and Galleries, meaning a list of objects and a list of
photograph sets.

GalleriesPanel, GalleryPicker and GalleryItems become Carousels*, the endpoint
becomes /fofos-admin/carousels, the block kind becomes carousel and
subject-gallery becomes subject-carousel. The publish allowlist follows the
renamed tables.

What did NOT move: GALLERY_CONFIG at /fofos-admin/gallery, subjects.ts's
base '/gallery', and home_gallery_media. Those are the Gallery section and the
legacy rows, and both are correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:01:15 +01:00
co-authored by Claude Opus 5
parent 5e5c06e76e
commit 15042ccc76
19 changed files with 148 additions and 148 deletions
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react';
import { Images, Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { readableError } from './errors';
/**
* Choose WHICH carousel a block shows.
*
* ── A reference, not a copy ──
*
* `carousels` is a named, ordered set built to be shown in more than one place — the old home carousel
* already read one by id. So a carousel block stores that id and owns nothing else: reordering the set
* reorders every block pointing at it, and a photograph swapped there is swapped everywhere. That is
* the whole reason the set has a name instead of being a list of files on a page.
*
* It follows that this picker chooses a SET, and editing what is IN the set happens where the set
* lives — under Carousels. A block that could also rearrange its own pictures would be a second place
* to do the same job, and the two would disagree.
*
* ── No thumbnails, deliberately ──
*
* The plugin has no way to address a rendition: `MediaPicker` hands that job to the platform's file
* browser, and every path stored here names an ORIGINAL that a browser cannot display. Rather than
* build a second, worse image pipeline for a dialog, this shows the name and the count — which is what
* tells two carousels apart, since the name is the thing somebody chose.
*/
export type CarouselSummary = {
id: number;
name: string;
slug: string;
items: unknown[];
};
export const CarouselPicker = ({
open,
onClose,
onPick,
selected,
}: {
open: boolean;
onClose: () => void;
onPick: (carouselId: number) => void;
/** The one this block already shows, so reopening the picker says where it stands. */
selected?: number | null;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [carousels, setCarousels] = useState<CarouselSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
// Re-read on every open rather than once. A carousel created in the other panel while this session
// has been running would otherwise be missing from a list that looks complete.
useEffect(() => {
if (!open) return;
setCarousels(null);
setError(null);
void (async () => {
try {
const response = await clientRef.current.get<{ data: CarouselSummary[] }>('/fofos-admin/carousels');
setCarousels(response.data);
} catch (failure) {
setError(readableError(failure));
}
})();
}, [open]);
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Choose a carousel</DialogTitle>
</DialogHeader>
{error && <p className="text-xs text-destructive">{error}</p>}
{!carousels && !error && (
<p className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Loading…
</p>
)}
{/* Nothing to choose is a real state and says what to do about it, rather than an empty box.
Creating one belongs under Carousels — see the note above about one place per job. */}
{carousels?.length === 0 && (
<p className="p-4 text-xs text-muted-foreground">
No carousels yet. Make one under Carousels, then come back.
</p>
)}
<div className="flex flex-col gap-1.5">
{carousels?.map((carousel) => {
const count = Array.isArray(carousel.items) ? carousel.items.length : 0;
const current = carousel.id === selected;
return (
<button
key={carousel.id}
type="button"
onClick={() => onPick(carousel.id)}
className={`flex items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
current ? 'border-primary bg-primary/10' : 'border-border hover:border-primary hover:bg-muted/50'
}`}
>
<Images className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{carousel.name}</span>
<span className="text-[11px] text-muted-foreground">
{count === 1 ? '1 item' : `${count} items`}
</span>
</span>
{current && <span className="text-[10px] tracking-wide text-primary uppercase">showing</span>}
</button>
);
})}
</div>
</DialogContent>
</Dialog>
);
};