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 { thumbUrl } from '../thumbnail'; /** * The site's partners: their logos, their names, their links, in order. * * ── ONE list, wherever the block appears ── * * A partners block places the site's partners; it does not hold a set of its own. So two pages that * both show them show the same people in the same order, and adding one adds it everywhere — which is * the whole reason it is a site singleton rather than page content. * * That is worth saying out loud in the panel, because the editor sits inside a block on a page and * everything else in that column belongs to the page. * * ── The same shape as CarouselItems ── * * Drag to reorder, a grip on the left, one write at the end carrying every id. Deliberately, so the * two ordered lists in this admin are the same gesture rather than two things to learn. */ type Partner = { id: number; name: string; logo_url: string; link_url: string | null }; export const PartnersEditor = ({ onChanged }: { onChanged?: () => void }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [partners, setPartners] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); const load = useCallback(async () => { try { setPartners((await clientRef.current.get<{ data: { partners: Partner[] } }>('/fofos-admin/site')).data.partners); } catch (failure) { setError(readableError(failure)); } }, []); useEffect(() => { void load(); }, [load]); const send = async (call: () => Promise) => { setBusy(true); setError(null); try { await call(); await load(); // Every page that shows them is now showing the old list. onChanged?.(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; /* Reordering, live. Same as CarouselItems — see the note there. */ const [dragged, setDragged] = useState(null); const orderedRef = useRef(null); const moveOver = (event: React.DragEvent, overId: number) => { if (dragged === null || !partners) return; event.preventDefault(); if (dragged === overId) return; const next = [...partners]; 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; setPartners(next); }; const commitOrder = () => { const ordered = orderedRef.current; setDragged(null); orderedRef.current = null; if (!ordered) return; void send(() => clientRef.current.put('/fofos-admin/site/partners/order', { ids: ordered.map((one) => one.id) })); }; if (!partners) return

{error ?? 'Loading…'}

; return (

The site's partners. The same list on every page that shows them.

    {partners.map((partner) => (
  • moveOver(event, partner.id)} onDrop={commitOrder} className={`flex items-center gap-2 rounded border border-border/60 p-1.5 ${ dragged === partner.id ? 'opacity-40' : '' }`} > { setDragged(partner.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(partner.id)); }} onDragEnd={commitOrder} className="shrink-0 cursor-grab text-muted-foreground/60 active:cursor-grabbing" aria-label={`Reorder ${partner.name}`} > { event.currentTarget.style.visibility = 'hidden'; }} /> {/* Saved on blur, not per keystroke — a name and a URL are things somebody is in the middle of typing, and a request per character would race with itself. */} { const value = event.target.value.trim(); if (!value || value === partner.name) return; void send(() => clientRef.current.patch(`/fofos-admin/site/partners/${partner.id}`, { name: value })); }} placeholder="Name" className="h-7 text-xs" /> { const value = event.target.value.trim(); if (value === (partner.link_url ?? '')) return; void send(() => clientRef.current.patch(`/fofos-admin/site/partners/${partner.id}`, { link_url: value || null }), ); }} placeholder="Link — optional" className="h-7 text-xs" />
  • ))}
{error &&

{error}

} setAdding(false)} onPick={(url) => { setAdding(false); /* Named from the FILE, and renamed in place afterwards. The API refuses a partner with no name, and asking for one in a second dialog before the logo has even been seen is a form in front of a form. The filename is a real answer often enough, and the field beside the logo is where it gets corrected. */ const name = (url.split('/').pop() ?? 'Partner').replace(/\.[^.]+$/, ''); void send(() => clientRef.current.post('/fofos-admin/site/partners', { name, logo_url: url })); }} title="Choose a partner's logo" />
); };