From 2ab9e787901007d27c07571c0a528647bb30c600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 06:56:36 +0100 Subject: [PATCH] Gallery is a component you can add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/GalleryPicker.tsx | 123 ++++++++++++++++++++++++++++ web/canvas.ts | 7 +- web/sections/AddComponentDialog.tsx | 9 +- web/sections/PageBuilderPanel.tsx | 61 +++++++++++++- 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 web/GalleryPicker.tsx diff --git a/web/GalleryPicker.tsx b/web/GalleryPicker.tsx new file mode 100644 index 0000000..2937dda --- /dev/null +++ b/web/GalleryPicker.tsx @@ -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(null); + const [error, setError] = useState(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 ( + !next && onClose()}> + + + Choose a gallery + + + {error &&

{error}

} + + {!galleries && !error && ( +

+ + Loading… +

+ )} + + {/* 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 && ( +

+ No galleries yet. Make one under Galleries, then come back. +

+ )} + +
+ {galleries?.map((gallery) => { + const count = Array.isArray(gallery.items) ? gallery.items.length : 0; + const current = gallery.id === selected; + return ( + + ); + })} +
+
+
+ ); +}; diff --git a/web/canvas.ts b/web/canvas.ts index 953bfce..28d97ae 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -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`: 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; }; export type CanvasSection = { diff --git a/web/sections/AddComponentDialog.tsx b/web/sections/AddComponentDialog.tsx index c8f4e49..cdf859d 100644 --- a/web/sections/AddComponentDialog.tsx +++ b/web/sections/AddComponentDialog.tsx @@ -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 = ({ diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 28835c1..6903955 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -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" /> + setChoosingGallery(null)} + onPick={(galleryId) => void saveGallery(galleryId)} + selected={choosingGallery?.galleryId ?? null} + /> + {spacing && (