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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user