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
+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;