a partners block edits the site's partners
The block places the list; it does not hold one. So this edits the singleton and every page showing them changes together — which the panel says out loud, because the editor sits inside a block on a page and everything else in that column belongs to the page. Same gesture as the gallery items: a grip on the left, live reorder, one write at the end carrying every id. Two ordered lists, one thing to learn. A new partner is named from its filename and renamed in the field beside it — the API refuses one with no name, and asking in a second dialog before the logo has been seen is a form in front of a form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ import { SpacingEditor, type Padding } from './SpacingEditor';
|
||||
import { BandSettings } from './BandSettings';
|
||||
import { BlockSettings } from './BlockSettings';
|
||||
import { GalleryItems } from './GalleryItems';
|
||||
import { PartnersEditor } from './PartnersEditor';
|
||||
import { useEditorSlot } from '../useEditorSlot';
|
||||
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
||||
import { MediaPicker } from '../MediaPicker';
|
||||
@@ -657,6 +658,12 @@ export const PageBuilderPanel = () => {
|
||||
A button rather than the modal opening itself, so picking the block and changing what is in
|
||||
it are two acts — see the note in the selection effect.
|
||||
*/}
|
||||
{/*
|
||||
Partners are the SITE's, not this page's — the block places the list rather than holding
|
||||
one. So this edits the singleton, and every page showing them changes together.
|
||||
*/}
|
||||
{block.kind === 'partners' && <PartnersEditor onChanged={() => setRendered((n) => n + 1)} />}
|
||||
|
||||
{block.kind === 'gallery' && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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 GalleryItems ──
|
||||
*
|
||||
* 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<Partner[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<unknown>) => {
|
||||
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 GalleryItems — see the note there. */
|
||||
const [dragged, setDragged] = useState<number | null>(null);
|
||||
const orderedRef = useRef<Partner[] | null>(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 <p className="text-xs text-muted-foreground">{error ?? 'Loading…'}</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
The site's partners. The same list on every page that shows them.
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-col gap-1">
|
||||
{partners.map((partner) => (
|
||||
<li
|
||||
key={partner.id}
|
||||
onDragOver={(event) => 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' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
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}`}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
|
||||
<img
|
||||
src={thumbUrl(partner.logo_url)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="size-12 shrink-0 rounded bg-muted object-contain"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Input
|
||||
defaultValue={partner.name}
|
||||
onBlur={(event) => {
|
||||
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"
|
||||
/>
|
||||
<Input
|
||||
defaultValue={partner.link_url ?? ''}
|
||||
onBlur={(event) => {
|
||||
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"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-1.5 text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void send(() => clientRef.current.delete(`/fofos-admin/site/partners/${partner.id}`))}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8 self-start text-xs" disabled={busy} onClick={() => setAdding(true)}>
|
||||
<Plus className="size-3.5" /> Add partner…
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<MediaPicker
|
||||
open={adding}
|
||||
onClose={() => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user