a gallery block edits its gallery
Add, remove, reorder and caption, in the editor column. A gallery block on a page IS a gallery, and being sent to another section to reorder it means losing the page you were arranging it for. It reads the gallery itself from its id, so it can be dropped anywhere something knows one. GalleriesPanel still draws its own copy of this list. That is a real duplicate and it should adopt this component — its version has the same operations against the same routes, plus creating and deleting whole galleries, which this does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { MediaPicker } from '../MediaPicker';
|
||||
import { readableError } from '../errors';
|
||||
import { thumbUrl } from '../thumbnail';
|
||||
|
||||
/**
|
||||
* One gallery's contents: what is in it, in what order, and what each picture is called.
|
||||
*
|
||||
* ── Why it is here and not a link to Galleries ──
|
||||
*
|
||||
* A gallery block on a page IS a gallery, and being sent to another section to reorder it means
|
||||
* losing the page you were arranging it for. The set is a thing of its own — that is why it has its
|
||||
* own section — but editing it is something you do while looking at where it appears.
|
||||
*
|
||||
* ── It reads the gallery itself ──
|
||||
*
|
||||
* `GET /galleries/:id`, rather than being handed one. The block knows an id and nothing else, and a
|
||||
* component that fetches what it needs can be dropped anywhere something knows an id.
|
||||
*
|
||||
* ── Duplication, stated plainly ──
|
||||
*
|
||||
* `GalleriesPanel` still draws its own copy of this list. That is a real duplicate and it should adopt
|
||||
* this component — its version carries the same operations against the same routes, plus creating and
|
||||
* deleting whole galleries, which this deliberately does not.
|
||||
*/
|
||||
|
||||
type Item = { id: number; url: string; alt: string | null };
|
||||
type Gallery = { id: number; name: string; slug: string; items: Item[] };
|
||||
|
||||
export const GalleryItems = ({ galleryId, onChanged }: { galleryId: number; onChanged?: () => void }) => {
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const [gallery, setGallery] = useState<Gallery | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [picking, setPicking] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setGallery((await clientRef.current.get<{ data: Gallery }>(`/fofos-admin/galleries/${galleryId}`)).data);
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
}
|
||||
}, [galleryId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/** Every write goes through here: one busy flag, one reload, one place errors surface. */
|
||||
const send = async (call: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await call();
|
||||
await load();
|
||||
// The page draws this gallery, so it is now showing the old one.
|
||||
onChanged?.();
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Every id, in order — which is what makes two items claiming a position impossible. */
|
||||
const move = (index: number, by: -1 | 1) => {
|
||||
if (!gallery) return;
|
||||
const next = [...gallery.items];
|
||||
const target = index + by;
|
||||
if (target < 0 || target >= next.length) return;
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
void send(() =>
|
||||
clientRef.current.put(`/fofos-admin/galleries/${galleryId}/items/order`, { ids: next.map((one) => one.id) }),
|
||||
);
|
||||
};
|
||||
|
||||
if (!gallery) return <p className="text-xs text-muted-foreground">{error ?? 'Loading…'}</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{gallery.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1">
|
||||
{gallery.items.map((item, index) => (
|
||||
<li key={item.id} className="flex items-center gap-2 rounded border border-border/60 p-1.5">
|
||||
{/*
|
||||
From the DERIVED ladder, which is the whole point of the pipeline: most of these are HEIC
|
||||
and a browser cannot show the original at all.
|
||||
*/}
|
||||
<img
|
||||
src={thumbUrl(item.url)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="size-12 shrink-0 rounded bg-muted object-cover"
|
||||
// A file whose conversion has not run has no rung. Hidden rather than left as a broken
|
||||
// glyph, which reads as a corrupt picture rather than a pending one.
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
{/*
|
||||
Saved on blur, not on every keystroke: a caption is a sentence somebody is in the
|
||||
middle of, and a request per character would race with itself.
|
||||
*/}
|
||||
<Input
|
||||
defaultValue={item.alt ?? ''}
|
||||
onBlur={(event) => {
|
||||
const value = event.target.value.trim();
|
||||
if (value === (item.alt ?? '')) return;
|
||||
void send(() =>
|
||||
clientRef.current.patch(`/fofos-admin/galleries/${galleryId}/items/${item.id}`, {
|
||||
alt: value || null,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
placeholder="Caption"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="flex shrink-0">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-1.5" disabled={busy || index === 0} onClick={() => move(index, -1)}>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-1.5"
|
||||
disabled={busy || index === gallery.items.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-1.5 text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void send(() => clientRef.current.delete(`/fofos-admin/galleries/${galleryId}/items/${item.id}`))
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8 self-start text-xs" disabled={busy} onClick={() => setPicking(true)}>
|
||||
<Plus className="size-3.5" /> Add media…
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<MediaPicker
|
||||
open={picking}
|
||||
onClose={() => setPicking(false)}
|
||||
onPick={(url) => {
|
||||
setPicking(false);
|
||||
void send(() => clientRef.current.post(`/fofos-admin/galleries/${galleryId}/items`, { urls: [url] }));
|
||||
}}
|
||||
title="Add to this gallery"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -36,6 +36,7 @@ import { FreeTextEditor } from './FreeTextEditor';
|
||||
import { SpacingEditor, type Padding } from './SpacingEditor';
|
||||
import { BandSettings } from './BandSettings';
|
||||
import { BlockSettings } from './BlockSettings';
|
||||
import { GalleryItems } from './GalleryItems';
|
||||
import { useEditorSlot } from '../useEditorSlot';
|
||||
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
||||
import { MediaPicker } from '../MediaPicker';
|
||||
@@ -657,6 +658,7 @@ export const PageBuilderPanel = () => {
|
||||
it are two acts — see the note in the selection effect.
|
||||
*/}
|
||||
{block.kind === 'gallery' && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -665,6 +667,18 @@ export const PageBuilderPanel = () => {
|
||||
>
|
||||
Choose gallery…
|
||||
</Button>
|
||||
|
||||
{/*
|
||||
The gallery's own contents, here rather than a link to the Galleries section.
|
||||
|
||||
A gallery block on a page IS a gallery, and being sent elsewhere to reorder it means
|
||||
losing the page you were arranging it for. `onChanged` reloads the frame, because the
|
||||
page draws this set and has just been given a different one.
|
||||
*/}
|
||||
{typeof block.content?.galleryId === 'number' && (
|
||||
<GalleryItems galleryId={block.content.galleryId} onChanged={() => setRendered((n) => n + 1)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(block.kind === 'media' || block.kind === 'video') && (
|
||||
|
||||
Reference in New Issue
Block a user