import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { Check, Images, Loader2, Pencil, Plus, Trash2, X } 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 { CarouselItems } from './CarouselItems'; /** * Carousels — named, ordered sets of media, reusable anywhere on the site. * * ── Why a carousel 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 carousel 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 Carousel = { id: number; name: string; slug: string; items: Item[] }; export const CarouselsPanel = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [carousels, setCarousels] = 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([]); /** The carousel being renamed, and the name being typed into it. Null when nobody is renaming. */ const [renaming, setRenaming] = useState<{ id: number; name: string } | null>(null); 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: Carousel[] }>('/fofos-admin/carousels'); setCarousels(response.data); } catch (failure) { setError(readableError(failure)); setCarousels([]); } }, []); useEffect(() => { void load(); }, [load]); /* The selected carousel'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 carousel over somebody else's screen. useEffect(() => () => setEditorRef.current(null), []); /* Arriving from "Carousel from folder" 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 carousel, seeing the slug it gets and watching the items land belongs on the screen that owns carousels. Consumed once and removed from the URL — a `?from=` left behind would recreate the carousel 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); } }; /* Renaming changes the NAME and leaves the slug exactly where it is. Everything that points at a carousel points at its id — `collections.carousel_id`, and a carousel block's `content.carouselId`. Nothing renders through the slug, so a rename cannot break a reference today. It is left alone anyway, because a slug is an ADDRESS: it can end up in a link somebody sent, and the day something does resolve by it, a label having quietly moved it would be a very hard bug to find. So the two are separable and this only writes one of them. A carousel whose slug is genuinely wrong is a different act with different consequences, and it should look like one. */ const rename = () => send('rename', async () => { if (!renaming?.name.trim()) return; await clientRef.current.patch(`/fofos-admin/carousels/${renaming.id}`, { name: renaming.name.trim() }); setRenaming(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 carousel behind whenever the second call failed. await clientRef.current.post('/fofos-admin/carousels', { name: name.trim(), urls: pending }); setName(''); setPending([]); }); if (!carousels) return
{error ?? 'Loading…'}
; return (

Carousels

{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 carousel" 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 carousel — its pictures, their order, their captions — are announced to the editor column and drawn by `CarouselItems`, which is the same component a carousel 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 carousels there are, making one, deleting one. What is inside the selected one is a different question, answered beside it. */} {carousels.map((carousel) => { const open = openId === carousel.id; return (
{renaming?.id === carousel.id ? (
setRenaming({ id: carousel.id, name: event.target.value })} onKeyDown={(event) => { if (event.key === 'Enter' && renaming.name.trim()) void rename(); if (event.key === 'Escape') setRenaming(null); }} /> {/* Said while renaming, not afterwards: somebody about to change a name is exactly who wonders whether the address moves with it. */} {carousel.slug} · the address does not change
) : ( )} {renaming?.id !== carousel.id && ( )}
); })}
); };