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