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
+3 -3
View File
@@ -41,7 +41,7 @@ const CONTENT_TABLES = [
/*
The page builder's own tables, and the reason they are FIRST.
Parents before children: a section belongs to a page and a component to a section. `galleries` sits
Parents before children: a section belongs to a page and a component to a section. `carousels` sits
below them because a gallery block names a set by id — carried in JSON rather than as a foreign
key, so nothing enforces the order, which is exactly why it is written down.
@@ -60,8 +60,8 @@ const CONTENT_TABLES = [
'home_content',
'home_gallery_media',
'about_content',
'galleries',
'gallery_items',
'carousels',
'carousel_items',
'collections',
'collection_items',
'collection_item_media',
@@ -5,17 +5,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { readableError } from './errors';
/**
* Choose WHICH gallery a block shows.
* Choose WHICH carousel a block shows.
*
* ── A reference, not a copy ──
*
* `galleries` 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 gallery block stores that id and owns nothing else: reordering the set
* `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 Galleries. A block that could also rearrange its own pictures would be a second place
* 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 ──
@@ -23,17 +23,17 @@ import { readableError } from './errors';
* 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 galleries apart, since the name is the thing somebody chose.
* tells two carousels apart, since the name is the thing somebody chose.
*/
export type GallerySummary = {
export type CarouselSummary = {
id: number;
name: string;
slug: string;
items: unknown[];
};
export const GalleryPicker = ({
export const CarouselPicker = ({
open,
onClose,
onPick,
@@ -41,7 +41,7 @@ export const GalleryPicker = ({
}: {
open: boolean;
onClose: () => void;
onPick: (galleryId: number) => void;
onPick: (carouselId: number) => void;
/** The one this block already shows, so reopening the picker says where it stands. */
selected?: number | null;
}) => {
@@ -49,19 +49,19 @@ export const GalleryPicker = ({
const clientRef = useRef(client);
clientRef.current = client;
const [galleries, setGalleries] = useState<GallerySummary[] | null>(null);
const [carousels, setCarousels] = useState<CarouselSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
// Re-read on every open rather than once. A gallery created in the other panel while this session
// 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;
setGalleries(null);
setCarousels(null);
setError(null);
void (async () => {
try {
const response = await clientRef.current.get<{ data: GallerySummary[] }>('/fofos-admin/galleries');
setGalleries(response.data);
const response = await clientRef.current.get<{ data: CarouselSummary[] }>('/fofos-admin/carousels');
setCarousels(response.data);
} catch (failure) {
setError(readableError(failure));
}
@@ -72,12 +72,12 @@ export const GalleryPicker = ({
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Choose a gallery</DialogTitle>
<DialogTitle>Choose a carousel</DialogTitle>
</DialogHeader>
{error && <p className="text-xs text-destructive">{error}</p>}
{!galleries && !error && (
{!carousels && !error && (
<p className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Loading…
@@ -85,29 +85,29 @@ export const GalleryPicker = ({
)}
{/* Nothing to choose is a real state and says what to do about it, rather than an empty box.
Creating one belongs under Galleries — see the note above about one place per job. */}
{galleries?.length === 0 && (
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 galleries yet. Make one under Galleries, then come back.
No carousels yet. Make one under Carousels, then come back.
</p>
)}
<div className="flex flex-col gap-1.5">
{galleries?.map((gallery) => {
const count = Array.isArray(gallery.items) ? gallery.items.length : 0;
const current = gallery.id === selected;
{carousels?.map((carousel) => {
const count = Array.isArray(carousel.items) ? carousel.items.length : 0;
const current = carousel.id === selected;
return (
<button
key={gallery.id}
key={carousel.id}
type="button"
onClick={() => onPick(gallery.id)}
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">{gallery.name}</span>
<span className="block truncate text-sm">{carousel.name}</span>
<span className="text-[11px] text-muted-foreground">
{count === 1 ? '1 item' : `${count} items`}
</span>
+2 -2
View File
@@ -7,7 +7,7 @@ import { AdministrationPanel } from './AdministrationPanel';
import { MediaPanel } from './MediaPanel';
import { CollectionsPanel } from './sections/CollectionsPanel';
import { PiecesPanel } from './sections/PiecesPanel';
import { GalleriesPanel } from './sections/GalleriesPanel';
import { CarouselsPanel } from './sections/CarouselsPanel';
import { LayoutPanel } from './sections/LayoutPanel';
import { PageBuilderPanel } from './sections/PageBuilderPanel';
import { ViewModeProvider } from './ViewMode';
@@ -50,7 +50,7 @@ const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
layout: LayoutPanel,
collections: CollectionsPanel,
pieces: PiecesPanel,
galleries: GalleriesPanel,
carousels: CarouselsPanel,
messages: MessagesPanel,
administration: AdministrationPanel,
media: MediaPanel,
+1 -1
View File
@@ -62,7 +62,7 @@ export const EditorPanel = () => {
{/*
Only where there is a page to look at.
The toggle sets the width the PREVIEW renders at, and Galleries, Media and Administration have
The toggle sets the width the PREVIEW renders at, and Carousels, Media and Administration have
no preview — the control would be three buttons that do nothing visible. Named here rather
than read from `usePreviewable`, which is a context inside the detail panel and this is its
sibling.
+2 -2
View File
@@ -60,9 +60,9 @@ export type CanvasComponent = {
/*
One optional field per kind that the panel reads back. Not `Record<string, unknown>`: every reader
would then need a cast, and the point of naming them is that the compiler knows `free-text` holds
`html` and `gallery` holds `galleryId`.
`html` and `gallery` holds `carouselId`.
*/
content?: { html?: string; galleryId?: number | null; url?: string; ratio?: number; crop?: string };
content?: { html?: string; carouselId?: number | null; url?: string; ratio?: number; crop?: string };
padding?: Record<string, { x?: number; y?: number }>;
};
export type CanvasSection = {
+5 -5
View File
@@ -29,8 +29,8 @@ const relativeTo = (path: string): string | null => {
export const fileActions: FileAction[] = [
{
key: 'fofos-admin:create-gallery',
label: 'Create Gallery',
key: 'fofos-admin:create-carousel',
label: 'Carousel from folder',
icon: Images,
/*
A folder, or any number of files — both are ways of saying "these, in this order".
@@ -76,10 +76,10 @@ export const fileActions: FileAction[] = [
paths = body.files;
}
/*
Handed over in the URL, and the Galleries panel does the asking.
Handed over in the URL, and the Carousels panel does the asking.
The action has no dialog of its own on purpose: naming a gallery, seeing the slug it will get
and watching the items land belongs on the screen that owns galleries, not in a modal over a
and watching the items land belongs on the screen that owns carousels, not in a modal over a
file browser that would then have to explain where the result went.
*/
// The section is a PATH now, so this names it rather than editing the current query. `create` and
@@ -87,7 +87,7 @@ export const fileActions: FileAction[] = [
const params = new URLSearchParams();
params.set('create', entries.length === 1 && entries[0]!.type === 'directory' ? entries[0]!.name : '');
params.set('from', paths.join('\n'));
window.history.pushState({}, '', `${href('galleries')}?${params.toString()}`);
window.history.pushState({}, '', `${href('carousels')}?${params.toString()}`);
// The router listens for this; pushState alone does not notify it.
window.dispatchEvent(new PopStateEvent('popstate'));
},
+2 -2
View File
@@ -18,7 +18,7 @@ import { DEFAULT_SECTION, isSectionKey, type SectionKey } from './sections';
* ── Why the pathname and not `useParams` ──
*
* Which parameters exist depends on which route matched: `/fofos-admin/layout/header` has a `band` and
* `/fofos-admin/galleries` has nothing. A panel would then have to know its own route to read its own
* `/fofos-admin/carousels` has nothing. A panel would then have to know its own route to read its own
* position, and every panel is rendered on all of them.
*
* Two segments after the mount, always in the same places. One reader, every route.
@@ -58,7 +58,7 @@ export const useNav = (): {
* A link to a section, or to something inside one.
*
* The query string is dropped, and that is the point rather than an omission. `?device=tablet` is how
* you are looking at the Layout preview; carrying it into Galleries would be state from one screen
* you are looking at the Layout preview; carrying it into Carousels would be state from one screen
* deciding something on another. A panel that wants a parameter to survive says so at its own link.
*/
export const href = (
+12 -12
View File
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react';
import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft, Shapes } from 'lucide-react';
import { Boxes, Cog, Files, FolderOpen, GalleryHorizontal, Images, Mail, PanelsTopLeft, Shapes } from 'lucide-react';
/**
* The sections of the fofos admin, in nav order.
@@ -40,7 +40,7 @@ export type SectionKey =
| 'administration'
| 'collections'
| 'pieces'
| 'galleries'
| 'carousels'
| 'media'
| 'messages'
| 'layout'
@@ -120,7 +120,7 @@ export const SECTIONS: Section[] = [
},
{
/*
Galleries — an entity of their own, not a list hanging off a page.
Carousels — an entity of their own, not a list hanging off a page.
Above Media and pinned with it, because the two are the same kind of thing: the raw material the
site is built from, held on this machine. Media is the files; a gallery is a set of them somebody
@@ -140,7 +140,7 @@ export const SECTIONS: Section[] = [
path: '/fofos-admin/collections',
built: true,
/*
Same reason as Galleries: a collection is a ROW read through the site's admin API, so this is
Same reason as Carousels: a collection is a ROW read through the site's admin API, so this is
useless without the development server answering. What is different is that the row is only half
of it — which collections EXIST is a question about `originals/COLLECTIONS/`, and the folders are
registered as this list is read.
@@ -153,9 +153,9 @@ export const SECTIONS: Section[] = [
The Gallery's pieces.
Called Pieces, not Gallery. The section on the site is Gallery and its items are peças, and there
is already a `galleries` section here meaning something else entirely — a named set of media. Two
nav rows called Gallery and Galleries, meaning a list of objects and a list of photograph sets,
is a question somebody would have to answer every time they looked at it.
is a Carousels section here meaning something else entirely — a named, ordered set of media. The
two were called Gallery and Galleries once, meaning a list of objects and a list of photograph
sets, and that is what the rename to Carousels was for.
Below Collections because it is the same object one level flatter, and it shares its panel — see
sections/subjects.ts.
@@ -172,12 +172,12 @@ export const SECTIONS: Section[] = [
pinned: true,
},
{
key: 'galleries',
label: 'Galleries',
icon: Images,
key: 'carousels',
label: 'Carousels',
icon: GalleryHorizontal,
hint: 'Named sets of media, reusable across the site',
// `/fofos-admin/galleries` — it reads the site's own admin API, unlike Media beside it.
path: '/fofos-admin/galleries',
// `/fofos-admin/carousels` — it reads the site's own admin API, unlike Media beside it.
path: '/fofos-admin/carousels',
built: true,
requires: 'connection',
pinned: true,
+6 -6
View File
@@ -26,7 +26,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
export type ComponentKind =
| 'free-text'
| 'media'
| 'gallery'
| 'carousel'
| 'contact-form'
| 'partners'
| 'collections'
@@ -35,7 +35,7 @@ export type ComponentKind =
| 'subject-title'
| 'subject-description'
| 'subject-specs'
| 'subject-gallery';
| 'subject-carousel';
/*
The blocks that draw whatever the page is ABOUT.
@@ -48,7 +48,7 @@ export const SUBJECT_KINDS: ComponentKind[] = [
'subject-title',
'subject-description',
'subject-specs',
'subject-gallery',
'subject-carousel',
];
type Choice = {
@@ -62,7 +62,7 @@ type Choice = {
* `kind: null` means not built.
*
* Named rather than omitted, and the ones named are things the site already HAS — media, video and
* galleries all exist as concepts here. That is the difference between saying what is coming and
* carousels all exist as concepts here. That is the difference between saying what is coming and
* inventing a roadmap. Gallery stopped being one of them on 2026-08-31: the set, its ordering and the
* carousel that draws it were all already built, so the block is a reference to one.
*/
@@ -77,7 +77,7 @@ export const CHOICES: Choice[] = [
always done.
*/
{ kind: 'media', label: 'Media', hint: 'A photograph or a video, from the media store', Icon: Image },
{ kind: 'gallery', label: 'Gallery', hint: 'A named set of media', Icon: Images },
{ kind: 'carousel', label: 'Carousel', hint: 'A named set of media', Icon: Images },
/*
Three that place something the site already draws.
@@ -108,7 +108,7 @@ export const CHOICES: Choice[] = [
{ kind: 'subject-title', label: 'Its name', hint: 'What this page is about', Icon: Heading },
{ kind: 'subject-description', label: 'Its description', hint: 'What was written about it', Icon: Type },
{ kind: 'subject-specs', label: 'Its specs', hint: 'Measurement, material, colours, and the rest', Icon: Ruler },
{ kind: 'subject-gallery', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images },
{ kind: 'subject-carousel', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images },
];
export const AddComponentDialog = ({
+1 -1
View File
@@ -13,7 +13,7 @@ import type { CanvasComponent, CanvasSection } from '../canvas';
*
* The page's structure nests this under every band, and the band's own editor shows just its own.
* They are the same list of the same things with the same actions, so they are the same component —
* the galleries panel taught that lesson once already.
* the carousels panel taught that lesson once already.
*
* ── Columns, because adding needs one ──
*
@@ -10,7 +10,7 @@ import { thumbUrl } from '../thumbnail';
/**
* One gallery's contents: what is in it, in what order, and what each picture is called.
*
* ── Why it is here and not a link to Galleries ──
* ── Why it is here and not a link to Carousels ──
*
* A gallery block on a page IS a gallery, and being sent to another section to reorder it means
* losing the page you were arranging it for. The set is a thing of its own — that is why it has its
@@ -18,36 +18,36 @@ import { thumbUrl } from '../thumbnail';
*
* ── It reads the gallery itself ──
*
* `GET /galleries/:id`, rather than being handed one. The block knows an id and nothing else, and a
* `GET /carousels/:id`, rather than being handed one. The block knows an id and nothing else, and a
* component that fetches what it needs can be dropped anywhere something knows an id.
*
* ── Duplication, stated plainly ──
*
* `GalleriesPanel` still draws its own copy of this list. That is a real duplicate and it should adopt
* `CarouselsPanel` still draws its own copy of this list. That is a real duplicate and it should adopt
* this component — its version carries the same operations against the same routes, plus creating and
* deleting whole galleries, which this deliberately does not.
* deleting whole carousels, which this deliberately does not.
*/
type Item = { id: number; url: string; alt: string | null };
type Gallery = { id: number; name: string; slug: string; items: Item[] };
export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onChanged?: () => void }) => {
export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; onChanged?: () => void }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [gallery, setGallery] = useState<Gallery | null>(null);
const [gallery, setCarousel] = useState<Gallery | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [picking, setPicking] = useState(false);
const load = useCallback(async () => {
try {
setGallery((await clientRef.current.get<{ data: Gallery }>(`/fofos-admin/galleries/${galleryId}`)).data);
setCarousel((await clientRef.current.get<{ data: Gallery }>(`/fofos-admin/carousels/${carouselId}`)).data);
} catch (failure) {
setError(readableError(failure));
}
}, [galleryId]);
}, [carouselId]);
useEffect(() => {
void load();
@@ -95,7 +95,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
next.splice(to, 0, next.splice(from, 1)[0]!);
orderedRef.current = next;
setGallery({ ...gallery, items: next });
setCarousel({ ...gallery, items: next });
};
const commitOrder = () => {
@@ -106,7 +106,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
if (!ordered) return;
void send(() =>
clientRef.current.put(`/fofos-admin/galleries/${galleryId}/items/order`, { ids: ordered.map((one) => one.id) }),
clientRef.current.put(`/fofos-admin/carousels/${carouselId}/items/order`, { ids: ordered.map((one) => one.id) }),
);
};
@@ -173,7 +173,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
const value = event.target.value.trim();
if (value === (item.alt ?? '')) return;
void send(() =>
clientRef.current.patch(`/fofos-admin/galleries/${galleryId}/items/${item.id}`, {
clientRef.current.patch(`/fofos-admin/carousels/${carouselId}/items/${item.id}`, {
alt: value || null,
}),
);
@@ -190,7 +190,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
className="h-7 px-1.5 text-destructive"
disabled={busy}
onClick={() =>
void send(() => clientRef.current.delete(`/fofos-admin/galleries/${galleryId}/items/${item.id}`))
void send(() => clientRef.current.delete(`/fofos-admin/carousels/${carouselId}/items/${item.id}`))
}
>
<Trash2 className="size-3.5" />
@@ -211,7 +211,7 @@ export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onCh
onClose={() => setPicking(false)}
onPick={(url) => {
setPicking(false);
void send(() => clientRef.current.post(`/fofos-admin/galleries/${galleryId}/items`, { urls: [url] }));
void send(() => clientRef.current.post(`/fofos-admin/carousels/${carouselId}/items`, { urls: [url] }));
}}
title="Add to this gallery"
/>
@@ -6,10 +6,10 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from '../errors';
import { useEditorSlot } from '../useEditorSlot';
import { GalleryItems } from './GalleryItems';
import { CarouselItems } from './CarouselItems';
/**
* Galleries — named, ordered sets of media, reusable anywhere on the site.
* Carousels — named, ordered sets of media, reusable anywhere on the site.
*
* ── Why a gallery is not a list on a page ──
*
@@ -28,12 +28,12 @@ import { GalleryItems } from './GalleryItems';
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 = () => {
export const CarouselsPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [galleries, setGalleries] = useState<Gallery[] | null>(null);
const [carousels, setCarousels] = 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. */
@@ -46,11 +46,11 @@ export const GalleriesPanel = () => {
const load = useCallback(async () => {
try {
const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/galleries');
setGalleries(response.data);
const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/carousels');
setCarousels(response.data);
} catch (failure) {
setError(readableError(failure));
setGalleries([]);
setCarousels([]);
}
}, []);
@@ -73,7 +73,7 @@ export const GalleriesPanel = () => {
useEffect(() => {
setEditorRef.current(
openId === null ? null : <GalleryItems galleryId={openId} onChanged={() => void load()} />,
openId === null ? null : <CarouselItems carouselId={openId} onChanged={() => void load()} />,
);
}, [openId, load]);
@@ -85,7 +85,7 @@ export const GalleriesPanel = () => {
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
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.
*/
const arriving = params.get('from');
@@ -124,8 +124,8 @@ 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
Everything that points at a gallery points at its id — `collections.carousel_id`, and a gallery
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.
@@ -136,7 +136,7 @@ export const GalleriesPanel = () => {
const rename = () =>
send('rename', async () => {
if (!renaming?.name.trim()) return;
await clientRef.current.patch(`/fofos-admin/galleries/${renaming.id}`, { name: renaming.name.trim() });
await clientRef.current.patch(`/fofos-admin/carousels/${renaming.id}`, { name: renaming.name.trim() });
setRenaming(null);
});
@@ -144,17 +144,17 @@ export const GalleriesPanel = () => {
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 });
await clientRef.current.post('/fofos-admin/carousels', { name: name.trim(), urls: pending });
setName('');
setPending([]);
});
if (!galleries) return <div className="p-5 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
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">Galleries</h3>
<h3 className="text-sm font-medium">Carousels</h3>
{error && <p className="text-xs text-destructive">{error}</p>}
@@ -193,13 +193,13 @@ export const GalleriesPanel = () => {
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
editor column and drawn by `CarouselItems`, 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
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.
*/}
{galleries.map((gallery) => {
{carousels.map((gallery) => {
const open = openId === gallery.id;
return (
<section
@@ -283,7 +283,7 @@ export const GalleriesPanel = () => {
className="shrink-0"
onClick={() =>
void send(`gallery:${gallery.id}`, () =>
clientRef.current.delete(`/fofos-admin/galleries/${gallery.id}`),
clientRef.current.delete(`/fofos-admin/carousels/${gallery.id}`),
)
}
disabled={busy !== null}
+12 -12
View File
@@ -48,7 +48,7 @@ type Home = {
/** The OLD per-page list. Still returned by the API, and only rendered while no gallery is chosen. */
gallery: GalleryItem[];
/** Which gallery the carousel shows. Null means the old list above still applies. */
gallery_id: number | null;
carousel_id: number | null;
};
type Gallery = { id: number; name: string; slug: string; items: unknown[] };
@@ -65,7 +65,7 @@ export const HomePanel = () => {
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
const [picking, setPicking] = useState(false);
const [galleries, setGalleries] = useState<Gallery[]>([]);
const [carousels, setCarousels] = useState<Gallery[]>([]);
/** Every write answers with the whole payload, so this is the only place state is set. */
const apply = useCallback((data: Home) => {
@@ -87,13 +87,13 @@ export const HomePanel = () => {
void load();
}, [load]);
// The galleries to choose between, read from the section that owns them rather than embedded in the
// The carousels to choose between, read from the section that owns them rather than embedded in the
// home payload — a copy there would go stale the moment somebody edited one.
useEffect(() => {
void clientRef.current
.get<{ data: Gallery[] }>('/fofos-admin/galleries')
.then((response) => setGalleries(response.data))
.catch(() => setGalleries([]));
.get<{ data: Gallery[] }>('/fofos-admin/carousels')
.then((response) => setCarousels(response.data))
.catch(() => setCarousels([]));
}, []);
const send = async (label: string, call: () => Promise<{ data: Home }>) => {
@@ -217,15 +217,15 @@ export const HomePanel = () => {
A picker, not a list.
Managing items here would be a second editor for something that already has one, and the two
would drift: a caption fixed in one place and not the other. The page chooses; the Galleries
would drift: a caption fixed in one place and not the other. The page chooses; the Carousels
section arranges.
*/}
<select
value={home.gallery_id ?? ''}
value={home.carousel_id ?? ''}
onChange={(event) =>
void send('gallery', () =>
void send('carousel', () =>
clientRef.current.patch<{ data: Home }>('/fofos-admin/home', {
gallery_id: event.target.value ? Number(event.target.value) : null,
carousel_id: event.target.value ? Number(event.target.value) : null,
}),
)
}
@@ -233,7 +233,7 @@ export const HomePanel = () => {
className="w-full rounded-md border border-border bg-background px-3 py-2 text-xs"
>
<option value="">None</option>
{galleries.map((gallery) => (
{carousels.map((gallery) => (
<option key={gallery.id} value={gallery.id}>
{gallery.name} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
</option>
@@ -242,7 +242,7 @@ export const HomePanel = () => {
{/* Only while it is true: the old rows are what renders until a gallery is chosen, and saying so
beats a carousel that appears to come from nowhere. */}
{home.gallery_id === null && home.gallery.length > 0 && (
{home.carousel_id === null && home.gallery.length > 0 && (
<p className="text-xs text-muted-foreground/70">
Showing {home.gallery.length} item{home.gallery.length === 1 ? '' : 's'} from the old
per-page list. Choosing a gallery replaces it.
+29 -29
View File
@@ -19,7 +19,7 @@ import { paintMeasure, paintSelection, type CanvasComponent, type CanvasSection
const LABELS: Record<string, string> = {
'free-text': 'Text',
media: 'Media',
gallery: 'Gallery',
carousel: 'Carousel',
'contact-form': 'Contact form',
partners: 'Partners',
collections: 'Collections',
@@ -28,7 +28,7 @@ const LABELS: Record<string, string> = {
'subject-title': 'Name',
'subject-description': 'Description',
'subject-specs': 'Specs',
'subject-gallery': 'Photographs',
'subject-carousel': 'Photographs',
};
/*
@@ -47,14 +47,14 @@ const PLACES_ONLY: string[] = [
'subject-title',
'subject-description',
'subject-specs',
'subject-gallery',
'subject-carousel',
];
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextEditor } from './FreeTextEditor';
import { SpacingEditor, type Padding } from './SpacingEditor';
import { BandSettings } from './BandSettings';
import { BlockSettings } from './BlockSettings';
import { GalleryItems } from './GalleryItems';
import { CarouselItems } from './CarouselItems';
import { PartnersEditor } from './PartnersEditor';
import { BlockMedia } from './BlockMedia';
import { PageStructure } from './PageStructure';
@@ -89,7 +89,7 @@ const shapeOf = (block: { content?: { url?: string; ratio?: number } }): string
import { useEditorSlot } from '../useEditorSlot';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
import { GalleryPicker } from '../GalleryPicker';
import { CarouselPicker } from '../CarouselPicker';
import { pageFromParam, pageParam, usePages, useSections, type Page } from '../usePages';
/**
@@ -165,14 +165,14 @@ export const PageBuilderPanel = () => {
Which gallery a block shows, while it is being chosen.
The same shape as `picking` and for the same reason: choosing a set is one act with one outcome, so
there is nothing to draft and nothing to cancel out of afterwards. `galleryId` rides along so
there is nothing to draft and nothing to cancel out of afterwards. `carouselId` rides along so
reopening the picker can show where the block already stands.
*/
const [choosingGallery, setChoosingGallery] = useState<{
const [choosingCarousel, setChoosingCarousel] = useState<{
id: number | null;
sectionId: number;
column: number;
galleryId: number | null;
carouselId: number | null;
} | null>(null);
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
@@ -248,7 +248,7 @@ export const PageBuilderPanel = () => {
/*
Selecting a block shows its settings. It does not open a picker.
`editComponent` means "edit what is IN this block", and for media and galleries that is a modal
`editComponent` means "edit what is IN this block", and for media and carousels that is a modal
over the whole screen. Firing it on mere selection meant clicking a row in the nav threw a dialog
at you before you had said you wanted one — those kinds get a button in the settings instead.
@@ -463,7 +463,7 @@ export const PageBuilderPanel = () => {
// Each kind opens its own editor. A kind with no case here cannot be created, which is the point.
if (kind === 'free-text') setEditing({ id: null, ...at, html: '' });
if (kind === 'media') setPicking({ id: null, ...at });
if (kind === 'gallery') setChoosingGallery({ id: null, ...at, galleryId: null });
if (kind === 'carousel') setChoosingCarousel({ id: null, ...at, carouselId: null });
if (PLACES_ONLY.includes(kind)) void createEmpty(kind, at);
};
@@ -494,23 +494,23 @@ export const PageBuilderPanel = () => {
};
/*
A gallery block is a REFERENCE, saved as soon as the set is chosen — see GalleryPicker.
A gallery block is a REFERENCE, saved as soon as the set is chosen — see CarouselPicker.
Identical in shape to saveVideo because it is the same interaction: pick one thing, store one
value. The two are left as two functions rather than one generic saver, because what they store
happens to be one field each today and a shared helper would be an abstraction over a coincidence.
*/
const saveGallery = async (galleryId: number) => {
if (!page || !choosingGallery) return;
const base = `/fofos-admin/pages/${page.id}/sections/${choosingGallery.sectionId}/components`;
const target = choosingGallery;
setChoosingGallery(null);
const saveCarousel = async (carouselId: number) => {
if (!page || !choosingCarousel) return;
const base = `/fofos-admin/pages/${page.id}/sections/${choosingCarousel.sectionId}/components`;
const target = choosingCarousel;
setChoosingCarousel(null);
setError(null);
try {
if (target.id === null) {
await clientRef.current.post(base, { kind: 'gallery', column: target.column, content: { galleryId } });
await clientRef.current.post(base, { kind: 'carousel', column: target.column, content: { carouselId } });
} else {
await clientRef.current.patch(`${base}/${target.id}`, { content: { galleryId } });
await clientRef.current.patch(`${base}/${target.id}`, { content: { carouselId } });
}
setRendered((n) => n + 1);
} catch (failure) {
@@ -590,12 +590,12 @@ export const PageBuilderPanel = () => {
}
if (component.kind === 'gallery') {
const held = owner.components.find((one) => one.id === component.id)?.content?.galleryId;
setChoosingGallery({
const held = owner.components.find((one) => one.id === component.id)?.content?.carouselId;
setChoosingCarousel({
id: component.id,
sectionId: owner.id,
column: component.column,
galleryId: typeof held === 'number' ? held : null,
carouselId: typeof held === 'number' ? held : null,
});
return;
}
@@ -805,14 +805,14 @@ export const PageBuilderPanel = () => {
</Button>
{/*
The gallery's own contents, here rather than a link to the Galleries section.
The gallery's own contents, here rather than a link to the Carousels section.
A gallery block on a page IS a gallery, and being sent elsewhere to reorder it means
losing the page you were arranging it for. `onChanged` reloads the frame, because the
page draws this set and has just been given a different one.
*/}
{typeof block.content?.galleryId === 'number' && (
<GalleryItems galleryId={block.content.galleryId} onChanged={() => setRendered((n) => n + 1)} />
{typeof block.content?.carouselId === 'number' && (
<CarouselItems carouselId={block.content.carouselId} onChanged={() => setRendered((n) => n + 1)} />
)}
</div>
)}
@@ -1050,11 +1050,11 @@ export const PageBuilderPanel = () => {
title="Choose a photograph or a video"
/>
<GalleryPicker
open={choosingGallery !== null}
onClose={() => setChoosingGallery(null)}
onPick={(galleryId) => void saveGallery(galleryId)}
selected={choosingGallery?.galleryId ?? null}
<CarouselPicker
open={choosingCarousel !== null}
onClose={() => setChoosingCarousel(null)}
onPick={(carouselId) => void saveCarousel(carouselId)}
selected={choosingCarousel?.carouselId ?? null}
/>
</div>
+2 -2
View File
@@ -19,7 +19,7 @@ import { thumbUrl } from '../thumbnail';
* That is worth saying out loud in the panel, because the editor sits inside a block on a page and
* everything else in that column belongs to the page.
*
* ── The same shape as GalleryItems ──
* ── The same shape as CarouselItems ──
*
* Drag to reorder, a grip on the left, one write at the end carrying every id. Deliberately, so the
* two ordered lists in this admin are the same gesture rather than two things to learn.
@@ -64,7 +64,7 @@ export const PartnersEditor = ({ onChanged }: { onChanged?: () => void }) => {
}
};
/* Reordering, live. Same as GalleryItems — see the note there. */
/* Reordering, live. Same as CarouselItems — see the note there. */
const [dragged, setDragged] = useState<number | null>(null);
const orderedRef = useRef<Partner[] | null>(null);
+1 -1
View File
@@ -42,7 +42,7 @@ type RecordRow = { id: number; title: string; sort_order: number } & Record<stri
* A thumbnail URL, authenticated by `?token=`.
*
* An `<img>` cannot send an Authorization header, which is why `user-middleware` and the permission
* gate both accept the token in the query string. The same thing GalleriesPanel and the file browser
* gate both accept the token in the query string. The same thing CarouselsPanel and the file browser
* do — built without it first, and every picture 401s while the layout looks perfectly correct.
*/
const thumbUrl = (path: string): string => {
+12 -12
View File
@@ -4,7 +4,7 @@ import { Images, Loader2, Plus, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { GalleryPicker } from '../GalleryPicker';
import { CarouselPicker } from '../CarouselPicker';
import { readableError } from '../errors';
import { useEditorSlot } from '../useEditorSlot';
import { thumbUrl } from '../thumbnail';
@@ -27,7 +27,7 @@ import type { SubjectPhoto, SubjectRecord, SubjectSection } from './subjects';
*
* The photographs belong to the gallery, and so does their order — including which one leads, which
* is simply the first. Making them look pressable here would promise a control that lives in
* Galleries.
* Carousels.
*/
export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: SubjectSection<T> }) => {
const client = useClient();
@@ -167,11 +167,11 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
);
});
const chooseGallery = (galleryId: number) =>
const chooseCarousel = (carouselId: number) =>
send(async () => {
if (!selected) return;
setPicking(false);
await clientRef.current.patch(`${section.endpoint}/${selected.id}`, { gallery_id: galleryId });
await clientRef.current.patch(`${section.endpoint}/${selected.id}`, { carousel_id: carouselId });
});
return (
@@ -230,11 +230,11 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
<div className="mb-4 flex flex-wrap items-center gap-2 border-b border-border pb-3">
<Button size="sm" variant="outline" className="h-8 gap-1.5 text-xs" onClick={() => setPicking(true)}>
<Images className="size-3.5" />
{selected.gallery_id ? 'Change gallery' : 'Choose a gallery'}
{selected.carousel_id ? 'Change gallery' : 'Choose a gallery'}
</Button>
<span className="min-w-0 flex-1" />
{/* Deleting removes the ROW, never the photographs — they belong to a gallery, which
belongs to Galleries. Said on the button so nobody has to find out. */}
belongs to Carousels. Said on the button so nobody has to find out. */}
<Button
size="sm"
variant="outline"
@@ -251,7 +251,7 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
{!selected ? (
<p className="text-xs text-muted-foreground">Choose a {section.noun}, or make one.</p>
) : !selected.gallery_id ? (
) : !selected.carousel_id ? (
<p className="text-xs text-muted-foreground">
No gallery yet. Choose one and its photographs appear here.
</p>
@@ -261,13 +261,13 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
</p>
) : photos.length === 0 ? (
<p className="text-xs text-muted-foreground">
That gallery is empty. Add photographs to it in Galleries.
That gallery is empty. Add photographs to it in Carousels.
</p>
) : (
<>
<p className="mb-3 text-xs text-muted-foreground">
{photos.length} photographs, in the gallery&rsquo;s order &mdash; the first one leads.
Reorder them in Galleries.
Reorder them in Carousels.
</p>
<ul className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
{photos.map((photo) => (
@@ -286,11 +286,11 @@ export const SubjectPanel = <T extends SubjectRecord>({ section }: { section: Su
{/* `selected` guarded: the picker writes to whatever is open, and there is nothing to write to
when nothing is. */}
{selected && (
<GalleryPicker
<CarouselPicker
open={picking}
onClose={() => setPicking(false)}
onPick={(galleryId) => void chooseGallery(galleryId)}
selected={selected.gallery_id}
onPick={(carouselId) => void chooseCarousel(carouselId)}
selected={selected.carousel_id}
/>
)}
</div>
+1 -1
View File
@@ -26,7 +26,7 @@ import { Boxes, Shapes } from 'lucide-react';
export type SubjectRecord = {
id: number;
/** The gallery holding its media, or null while nobody has chosen one. */
gallery_id: number | null;
carousel_id: number | null;
title: string;
slug: string | null;
description: string | null;
+1 -1
View File
@@ -11,7 +11,7 @@ import { getHeaders } from 'hooks/useClient';
*
* ── Why this is a module ──
*
* It was copied into GalleriesPanel and again into RecordsPanel, and a third copy was about to go into
* It was copied into CarouselsPanel and again into RecordsPanel, and a third copy was about to go into
* MediaField. Three copies of a URL shape is three places to be wrong about the rung when the ladder
* changes — which it did, four times in a day.
*/