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') && (