import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { Images, Loader2, Plus, Trash2 } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { readableError } from '../errors'; import { useEditorSlot } from '../useEditorSlot'; import { GalleryItems } from './GalleryItems'; /** * Galleries — named, ordered sets of media, reusable anywhere on the site. * * ── Why a gallery is not a list on a page ── * * The home page had one, as rows hanging off its own content. That works for exactly one. The moment a * second page wants the same photographs in the same order, a list-per-page means choosing them again * and keeping two copies in step by hand. * * ── The server owns the shape ── * * Every write answers with the whole gallery and this never patches its own state from what it sent. * That matters most here: reordering renumbers everything, adding appends after whatever was there, * and deleting closes a gap. A client keeping its own copy drifts on the first of those with no way to * notice. */ type Item = { id: number; url: string; alt: string | null; sort_order: number }; type Gallery = { id: number; name: string; slug: string; items: Item[] }; export const GalleriesPanel = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [galleries, setGalleries] = useState(null); const [openId, setOpenId] = useState(null); const [name, setName] = useState(''); /** Paths handed over by the context menu, waiting for a name. */ const [pending, setPending] = useState([]); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [params, setParams] = useSearchParams(); const load = useCallback(async () => { try { const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/galleries'); setGalleries(response.data); } catch (failure) { setError(readableError(failure)); setGalleries([]); } }, []); useEffect(() => { void load(); }, [load]); /* The selected gallery's contents, handed to the editor column. Announced rather than rendered, because that panel is a SIBLING — see useEditorSlot. `onChanged` reloads the list here, so the item count beside the name is never one behind what is in it. The dependency list is everything the editor DISPLAYS: an element captures the props it was built with, so one left out reads as a control that has stopped responding rather than as an error. */ const [, setEditor] = useEditorSlot(); const setEditorRef = useRef(setEditor); setEditorRef.current = setEditor; useEffect(() => { setEditorRef.current( openId === null ? null : void load()} />, ); }, [openId, load]); // Cleared on unmount, or the panel goes on offering a gallery over somebody else's screen. useEffect(() => () => setEditorRef.current(null), []); /* Arriving from "Create Gallery" in the file browser. The action puts the chosen paths in the URL and sends you here rather than opening its own dialog: naming a gallery, seeing the slug it gets and watching the items land belongs on the screen that owns galleries. Consumed once and removed from the URL — a `?from=` left behind would recreate the gallery on every refresh, which somebody discovers as four copies. */ const arriving = params.get('from'); useEffect(() => { if (arriving === null) return; const urls = arriving.split('\n').filter(Boolean); const suggested = params.get('create') ?? ''; setParams( (current) => { const next = new URLSearchParams(current); next.delete('from'); next.delete('create'); return next; }, { replace: true }, ); // Pre-filled rather than created: the name is somebody's to choose and a folder name is only a // suggestion. Nothing is written until Create. setName(suggested); setPending(urls); }, [arriving, params, setParams]); const send = async (label: string, call: () => Promise) => { setBusy(label); setError(null); try { await call(); await load(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(null); } }; const create = () => send('create', async () => { // `urls` in the same request, so creating from a selection is one action — splitting it would // leave an empty gallery behind whenever the second call failed. await clientRef.current.post('/fofos-admin/galleries', { name: name.trim(), urls: pending }); setName(''); setPending([]); }); if (!galleries) return
{error ?? 'Loading…'}
; return (

Galleries

{error &&

{error}

} {/* What the context menu sent, so what is about to be created is not a mystery. */} {pending.length > 0 && (

{pending.length} item{pending.length === 1 ? '' : 's'} ready — name it and press Create.

)}
0} value={name} onChange={(event) => setName(event.target.value)} placeholder="Name a new gallery" className="min-w-0 flex-1 text-xs" onKeyDown={(event) => { if (event.key === 'Enter' && name.trim()) void create(); }} />
{/* The list, and only the list. The contents of a gallery — its pictures, their order, their captions — are announced to the editor column and drawn by `GalleryItems`, which is the same component a gallery BLOCK on a page uses. Two copies of that list existed for a day and this is the one that went. So this panel is what a master list should be: which galleries there are, making one, deleting one. What is inside the selected one is a different question, answered beside it. */} {galleries.map((gallery) => { const open = openId === gallery.id; return (
); })}
); };