import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { ArrowDown, ArrowUp, FolderOpen, 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 { getHeaders } from 'hooks/useClient'; import { readableError } from '../errors'; import { MediaPicker } from '../MediaPicker'; /** * 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[] }; /** * A thumbnail URL, authenticated by `?token=`. * * An `` cannot send an Authorization header, which is why `user-middleware` and the permission * gate both accept the token in the query string — the same thing the file browser's own thumbnails do. * Built without it first, and every picture 401'd while the layout looked perfectly correct. */ const thumbUrl = (path: string): string => { const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? ''; const encoded = path.split('/').map(encodeURIComponent).join('/'); return `/api/fofos-admin/derived/${encoded}/320.webp?token=${encodeURIComponent(token)}`; }; 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(''); const [picking, setPicking] = useState(null); /** 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]); /* 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([]); }); const addTo = (galleryId: number, url: string) => send(`add:${galleryId}`, () => clientRef.current.post(`/fofos-admin/galleries/${galleryId}/items`, { urls: [url] })); const removeItem = (galleryId: number, itemId: number) => send(`del:${itemId}`, () => clientRef.current.delete(`/fofos-admin/galleries/${galleryId}/items/${itemId}`)); /** Every id, in order — which is what makes two items claiming a position impossible. */ const move = (gallery: Gallery, index: number, by: -1 | 1) => { const next = [...gallery.items]; const target = index + by; if (target < 0 || target >= next.length) return; [next[index], next[target]] = [next[target]!, next[index]!]; return send('order', () => clientRef.current.put(`/fofos-admin/galleries/${gallery.id}/items/order`, { ids: next.map((item) => item.id) }), ); }; 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(); }} />
{galleries.map((gallery) => { const open = openId === gallery.id; return (
{open && ( <>
    {gallery.items.map((item, index) => (
  • {index + 1} {/* 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. 320 is the smallest rung and still sharp at this size on a retina screen. */} { event.currentTarget.style.visibility = 'hidden'; }} /> {item.url} {/* Saved on blur rather than on every keystroke: a caption is a sentence somebody is in the middle of, and a request per character would be both wasteful and a race with itself. */} { if (event.target.value.trim() !== (item.alt ?? '')) { void send(`alt:${item.id}`, () => clientRef.current.patch(`/fofos-admin/galleries/${gallery.id}/items/${item.id}`, { alt: event.target.value.trim() || null, }), ); } }} placeholder="Caption — describe it, for screen readers" className="h-7 text-xs" />
  • ))} {!gallery.items.length &&
  • Nothing in it yet.
  • }
)}
); })} setPicking(null)} onPick={(path) => { if (picking !== null) void addTo(picking, path); }} title="Add to gallery" />
); };