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:
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user