From d4842c9c3e8c2b7d23b810a320b3fa961af89827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 20:35:15 +0100 Subject: [PATCH] a gallery block edits its gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/sections/GalleryItems.tsx | 181 ++++++++++++++++++++++++++++++ web/sections/PageBuilderPanel.tsx | 30 +++-- 2 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 web/sections/GalleryItems.tsx diff --git a/web/sections/GalleryItems.tsx b/web/sections/GalleryItems.tsx new file mode 100644 index 0000000..984c63a --- /dev/null +++ b/web/sections/GalleryItems.tsx @@ -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(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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) => { + 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

{error ?? 'Loading…'}

; + + return ( +
+
+ {gallery.name} + + {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'} + +
+ +
    + {gallery.items.map((item, index) => ( +
  • + {/* + 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. + */} + { + event.currentTarget.style.visibility = 'hidden'; + }} + /> + + + {/* + 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. + */} + { + 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" + /> + + + + + + + +
  • + ))} +
+ + + + {error &&

{error}

} + + setPicking(false)} + onPick={(url) => { + setPicking(false); + void send(() => clientRef.current.post(`/fofos-admin/galleries/${galleryId}/items`, { urls: [url] })); + }} + title="Add to this gallery" + /> +
+ ); +}; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 9bff87f..990b1d2 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -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,14 +658,27 @@ export const PageBuilderPanel = () => { it are two acts — see the note in the selection effect. */} {block.kind === 'gallery' && ( - +
+ + + {/* + 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' && ( + setRendered((n) => n + 1)} /> + )} +
)} {(block.kind === 'media' || block.kind === 'video') && (