import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { Boxes, Check, Loader2 } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { useEditorSlot } from '../useEditorSlot'; import { thumbUrl } from '../thumbnail'; import { CollectionFields } from './CollectionFields'; import type { Collection, CollectionPhoto } from './collections'; /** * Collections — one folder of photographs each, and what is said about it. * * ── The list is the DISK ── * * Which collections exist is a question about `originals/COLLECTIONS/`, not about a table. The API * registers every folder as it reads them, so a collection appears here the moment its folder does and * there is no "create" button: making one means making a folder, in Media or in Finder, and both work. * * Deleting is the same in reverse and is deliberately not offered. A button here would either remove a * row and leave the photographs — so the collection reappears on the next read — or delete somebody's * pictures. Neither is what a Delete button in an admin should mean. * * ── The grid is for choosing the COVER ── * * The photographs are already ordered by their filenames and the gallery is the folder, so there is * nothing to reorder and nothing to add. The one decision is which picture leads, and that is a click. */ export const CollectionsPanel = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [collections, setCollections] = useState(null); const [photos, setPhotos] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Selection in the URL, like every other screen here — see docs/navigation-audit.md. const [params, setParams] = useSearchParams(); const selectedId = Number(params.get('collection')) || null; const selected = collections?.find((one) => one.id === selectedId) ?? null; const load = useCallback(async () => { try { const response = await clientRef.current.get<{ data: Collection[] }>('/fofos-admin/collections'); setCollections(response.data); } catch (failure) { setError(readableError(failure)); setCollections([]); } }, []); useEffect(() => { void load(); }, [load]); // The folder's contents, re-read when the selection moves. Null while nothing is chosen, so the // empty state and "still loading" are not the same thing. useEffect(() => { if (!selectedId) { setPhotos(null); return; } let alive = true; setPhotos(null); void clientRef.current .get<{ data: CollectionPhoto[] }>(`/fofos-admin/collections/${selectedId}/media`) .then((response) => alive && setPhotos(response.data)) .catch((failure: unknown) => alive && setError(readableError(failure))); return () => { alive = false; }; }, [selectedId]); /* The fields, in the editor column — a SIBLING panel, so they are announced rather than rendered. See useEditorSlot. */ const [, setEditor] = useEditorSlot(); const setEditorRef = useRef(setEditor); setEditorRef.current = setEditor; useEffect(() => { setEditorRef.current( selected ? void load()} /> : null, ); }, [selected, load]); useEffect(() => () => setEditorRef.current(null), []); const chooseCover = async (name: string) => { if (!selected) return; setBusy(true); setError(null); try { // Pressing the one already chosen clears it, which is how "no choice, use the first" is reachable // again without a second control that only does that. const next = selected.cover === name ? null : name; await clientRef.current.patch(`/fofos-admin/collections/${selected.id}`, { cover: next }); await load(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; const select = (id: number) => setParams( (current) => { const next = new URLSearchParams(current); next.set('collection', String(id)); return next; }, { replace: true }, ); return (
    {collections === null ? (
  • Reading the folders…
  • ) : collections.length === 0 ? (
  • Nothing under originals/COLLECTIONS yet.
  • ) : ( collections.map((collection) => (
  • )) )}
{error &&

{error}

} {!selected ? (

Choose a collection.

) : photos === null ? (

Reading {selected.folder}…

) : photos.length === 0 ? (

That folder is empty. Put photographs in {selected.folder}.

) : ( <>

{photos.length} photographs, in filename order. Click one to make it the main photo.

    {photos.map((photo) => { const isCover = selected.cover ? photo.name === selected.cover : photo === photos[0]; return (
  • {photo.name}

  • ); })}
)}
); };