a gallery is a carousel
It always rendered as one — data-carousel, a snap track, two arrows, driven by carousel.client.js. The entity was the last thing still calling it something else, and that collided with the site's own Gallery section: two nav rows reading Gallery and Galleries, meaning a list of objects and a list of photograph sets. GalleriesPanel, GalleryPicker and GalleryItems become Carousels*, the endpoint becomes /fofos-admin/carousels, the block kind becomes carousel and subject-gallery becomes subject-carousel. The publish allowlist follows the renamed tables. What did NOT move: GALLERY_CONFIG at /fofos-admin/gallery, subjects.ts's base '/gallery', and home_gallery_media. Those are the Gallery section and the legacy rows, and both are correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* One gallery'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 gallery block on a page IS a gallery, 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 gallery 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 Gallery = { 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 [gallery, setCarousel] = useState<Gallery | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [picking, setPicking] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setCarousel((await clientRef.current.get<{ data: Gallery }>(`/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<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await call();
|
||||
await load();
|
||||
// The page draws this gallery, 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<number | null>(null);
|
||||
const orderedRef = useRef<Item[] | null>(null);
|
||||
|
||||
const moveOver = (event: React.DragEvent, overId: number) => {
|
||||
if (dragged === null || !gallery) 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 = [...gallery.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({ ...gallery, 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 (!gallery) return <p className="text-xs text-muted-foreground">{error ?? 'Loading…'}</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{gallery.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1">
|
||||
{gallery.items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
/* The row is the drop target; the grip is what drags. See the note on the handler. */
|
||||
onDragOver={(event) => 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' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
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"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<img
|
||||
src={thumbUrl(item.url)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="size-12 shrink-0 rounded bg-muted object-cover"
|
||||
// A file whose conversion has not run has no rung. Hidden rather than left as a broken
|
||||
// glyph, which reads as a corrupt picture rather than a pending one.
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Input
|
||||
defaultValue={item.alt ?? ''}
|
||||
onBlur={(event) => {
|
||||
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"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="flex shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-1.5 text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void send(() => clientRef.current.delete(`/fofos-admin/carousels/${carouselId}/items/${item.id}`))
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8 self-start text-xs" disabled={busy} onClick={() => setPicking(true)}>
|
||||
<Plus className="size-3.5" /> Add media…
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<MediaPicker
|
||||
open={picking}
|
||||
onClose={() => setPicking(false)}
|
||||
onPick={(url) => {
|
||||
setPicking(false);
|
||||
void send(() => clientRef.current.post(`/fofos-admin/carousels/${carouselId}/items`, { urls: [url] }));
|
||||
}}
|
||||
title="Add to this gallery"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user