Gallery is a component you can add

The picker listed it as `soon` beside Image. It is not soon any more — the set, its ordering and the
carousel that draws it were already built on the site, so the block is a reference to one and the
admin's whole job is choosing which.

`GalleryPicker` chooses a SET and nothing else. Editing what is IN the set stays under Galleries: a
block that could also rearrange its own pictures would be a second place to do one job, and the two
would disagree. No thumbnails — every stored path names an original a browser cannot display, and the
plugin has no way to address a rendition, so the name and the count identify it instead.

Saved on choose, like a video and for the same reason: picking one thing is one act with one outcome,
so there is nothing to draft and nothing to cancel out of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 06:56:36 +01:00
co-authored by Claude Opus 5
parent 18c01de6f2
commit 2ab9e78790
4 changed files with 194 additions and 6 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 gallery 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
* 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
* 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 galleries apart, since the name is the thing somebody chose.
*/
export type GallerySummary = {
id: number;
name: string;
slug: string;
items: unknown[];
};
export const GalleryPicker = ({
open,
onClose,
onPick,
selected,
}: {
open: boolean;
onClose: () => void;
onPick: (galleryId: 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 [galleries, setGalleries] = useState<GallerySummary[] | 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
// has been running would otherwise be missing from a list that looks complete.
useEffect(() => {
if (!open) return;
setGalleries(null);
setError(null);
void (async () => {
try {
const response = await clientRef.current.get<{ data: GallerySummary[] }>('/fofos-admin/galleries');
setGalleries(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 gallery</DialogTitle>
</DialogHeader>
{error && <p className="text-xs text-destructive">{error}</p>}
{!galleries && !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 Galleries — see the note above about one place per job. */}
{galleries?.length === 0 && (
<p className="p-4 text-xs text-muted-foreground">
No galleries yet. Make one under Galleries, 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;
return (
<button
key={gallery.id}
type="button"
onClick={() => onPick(gallery.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="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>
);
};
+6 -1
View File
@@ -53,7 +53,12 @@ export type CanvasComponent = {
id: number;
column: number;
kind: string;
content?: { html?: string };
/*
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`.
*/
content?: { html?: string; galleryId?: number | null };
padding?: Record<string, { x?: number; y?: number }>;
};
export type CanvasSection = {
+5 -4
View File
@@ -23,7 +23,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
* A kind the server would accept and this has never heard of is not creatable anyway.
*/
export type ComponentKind = 'free-text' | 'video';
export type ComponentKind = 'free-text' | 'video' | 'gallery';
type Choice = {
kind: ComponentKind | null;
@@ -35,15 +35,16 @@ type Choice = {
/**
* `kind: null` means not built.
*
* Named rather than omitted, and the three named are things the site already HAS — media, video and
* 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
* inventing a roadmap.
* 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.
*/
const CHOICES: Choice[] = [
{ kind: 'free-text', label: 'Text', hint: 'Words, headings and links', Icon: Type },
{ kind: null, label: 'Image', hint: 'One picture from the media store', Icon: Images },
{ kind: 'video', label: 'Video', hint: 'No controls, a poster, autoplay and loop', Icon: Video },
{ kind: null, label: 'Gallery', hint: 'A named set of media', Icon: Images },
{ kind: 'gallery', label: 'Gallery', hint: 'A named set of media', Icon: Images },
];
export const AddComponentDialog = ({
+60 -1
View File
@@ -10,6 +10,7 @@ import { FreeTextEditor } from './FreeTextEditor';
import { SpacingEditor, type Padding } from './SpacingEditor';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
import { GalleryPicker } from '../GalleryPicker';
import { pageFromParam, usePages, type Page } from '../usePages';
/**
@@ -76,6 +77,20 @@ export const PageBuilderPanel = () => {
/** Where a component is about to be added, while the kind is being chosen. */
const [choosing, setChoosing] = useState<{ sectionId: number; column: number } | null>(null);
/** The component being written, and where a new one would go. Null when the dialog is shut. */
/*
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
reopening the picker can show where the block already stands.
*/
const [choosingGallery, setChoosingGallery] = useState<{
id: number | null;
sectionId: number;
column: number;
galleryId: number | null;
} | null>(null);
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
null,
);
@@ -231,6 +246,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, ...choosing, html: '' });
if (kind === 'video') setPicking({ id: null, ...choosing });
if (kind === 'gallery') setChoosingGallery({ id: null, ...choosing, galleryId: null });
};
/*
@@ -256,6 +272,31 @@ export const PageBuilderPanel = () => {
}
};
/*
A gallery block is a REFERENCE, saved as soon as the set is chosen — see GalleryPicker.
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);
setError(null);
try {
if (target.id === null) {
await clientRef.current.post(base, { kind: 'gallery', column: target.column, content: { galleryId } });
} else {
await clientRef.current.patch(`${base}/${target.id}`, { content: { galleryId } });
}
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
const editComponent = (component: CanvasComponent) => {
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
if (!owner) return;
@@ -267,6 +308,17 @@ export const PageBuilderPanel = () => {
return;
}
if (component.kind === 'gallery') {
const held = owner.components.find((one) => one.id === component.id)?.content?.galleryId;
setChoosingGallery({
id: component.id,
sectionId: owner.id,
column: component.column,
galleryId: typeof held === 'number' ? held : null,
});
return;
}
const stored = owner.components.find((one) => one.id === component.id);
const html = typeof stored?.content?.html === 'string' ? stored.content.html : '';
setEditing({ id: component.id, sectionId: owner.id, column: component.column, html });
@@ -366,7 +418,7 @@ export const PageBuilderPanel = () => {
kind: 'component',
id: component.id,
sectionId: owner.id,
label: component.kind === 'video' ? 'Video' : 'Text',
label: component.kind === 'video' ? 'Video' : component.kind === 'gallery' ? 'Gallery' : 'Text',
padding: (stored?.padding ?? {}) as Padding,
});
};
@@ -419,6 +471,13 @@ export const PageBuilderPanel = () => {
title="Choose a video"
/>
<GalleryPicker
open={choosingGallery !== null}
onClose={() => setChoosingGallery(null)}
onPick={(galleryId) => void saveGallery(galleryId)}
selected={choosingGallery?.galleryId ?? null}
/>
{spacing && (
<SpacingEditor
label={spacing.label}