import { useCallback, useEffect, useRef, useState } from 'react'; import { GripVertical, 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 { mediaThumbUrl } from '../thumbnail'; /** * One carousel's contents: what is in it, in what order, and what each picture is called. * * ── Why it is here and not a link to Carousels ── * * A carousel block on a page IS a carousel, 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 carousel itself ── * * `GET /carousels/: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 ── * * `CarouselsPanel` 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 carousels, which this deliberately does not. */ type Item = { id: number; url: string; alt: string | null }; type Carousel = { id: number; name: string; slug: string; items: Item[] }; export const CarouselItems = ({ carouselId, onChanged }: { carouselId: number; onChanged?: () => void }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [carousel, setCarousel] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [picking, setPicking] = useState(false); const load = useCallback(async () => { try { setCarousel((await clientRef.current.get<{ data: Carousel }>(`/fofos-admin/carousels/${carouselId}`)).data); } catch (failure) { setError(readableError(failure)); } }, [carouselId]); 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 carousel, so it is now showing the old one. onChanged?.(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; /* Reordering by dragging, the same way the page rows do it. The list rearranges as the pointer crosses a row rather than on drop, so what you see while dragging is what you will get. The write happens once, at the end, and it sends EVERY id in order — which is what makes two items claiming a position impossible. It replaced a pair of arrows per row. Nine items meant eighteen buttons and four presses to move something from the bottom to the top. */ const [dragged, setDragged] = useState(null); const orderedRef = useRef(null); const moveOver = (event: React.DragEvent, overId: number) => { if (dragged === null || !carousel) return; // Without this the drop is refused and the browser animates the row back to where it started. event.preventDefault(); if (dragged === overId) return; const next = [...carousel.items]; const from = next.findIndex((one) => one.id === dragged); const to = next.findIndex((one) => one.id === overId); if (from < 0 || to < 0) return; next.splice(to, 0, next.splice(from, 1)[0]!); orderedRef.current = next; setCarousel({ ...carousel, items: next }); }; const commitOrder = () => { const ordered = orderedRef.current; setDragged(null); orderedRef.current = null; // Dropped where it started, or outside the list. Nothing moved, so nothing is written. if (!ordered) return; void send(() => clientRef.current.put(`/fofos-admin/carousels/${carouselId}/items/order`, { ids: ordered.map((one) => one.id) }), ); }; if (!carousel) return

{error ?? 'Loading…'}

; return (
{carousel.name} {carousel.items.length} item{carousel.items.length === 1 ? '' : 's'}
    {carousel.items.map((item) => (
  • moveOver(event, item.id)} onDrop={commitOrder} className={`flex items-center gap-2 rounded border border-border/60 p-1.5 ${ dragged === item.id ? 'opacity-40' : '' }`} > { setDragged(item.id); // Firefox starts no drag at all without data on the transfer. The value is unused. event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(item.id)); }} onDragEnd={commitOrder} className="shrink-0 cursor-grab text-muted-foreground/60 active:cursor-grabbing" aria-label="Reorder" > {/* 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/carousels/${carouselId}/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/carousels/${carouselId}/items`, { urls: [url] })); }} title="Add to this carousel" />
); };