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>
141 lines
5.2 KiB
TypeScript
141 lines
5.2 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { GripVertical, Plus, Trash2 } from 'lucide-react';
|
|
import { Link } from 'react-router';
|
|
import { Button } from '@/components/ui/button';
|
|
import { href } from '../routes';
|
|
import { CHOICES } from './AddComponentDialog';
|
|
import type { CanvasComponent, CanvasSection } from '../canvas';
|
|
|
|
/**
|
|
* What is in one band, column by column.
|
|
*
|
|
* ── One list, two places ──
|
|
*
|
|
* The page's structure nests this under every band, and the band's own editor shows just its own.
|
|
* They are the same list of the same things with the same actions, so they are the same component —
|
|
* the carousels panel taught that lesson once already.
|
|
*
|
|
* ── Columns, because adding needs one ──
|
|
*
|
|
* A block goes IN a column, so a flat list cannot express adding one. A one-column band shows a single
|
|
* button and never has to ask a question it has no answer to; a two-column band shows Add left and Add
|
|
* right.
|
|
*
|
|
* ── Reorder is within a column ──
|
|
*
|
|
* The write sends the column alongside the ids, and the query sets both — so dragging a block from one
|
|
* column to the other is the same call, on the day the interface offers it. It does not yet: a list
|
|
* per column has no gesture for crossing between them, and inventing one here would be guessing.
|
|
*/
|
|
export const BandBlocks = ({
|
|
slug,
|
|
section,
|
|
busy,
|
|
onAdd,
|
|
onDelete,
|
|
onReorder,
|
|
}: {
|
|
slug: string;
|
|
section: CanvasSection;
|
|
busy: boolean;
|
|
onAdd: (section: CanvasSection, column: number) => void;
|
|
onDelete: (block: CanvasComponent) => void;
|
|
onReorder: (section: CanvasSection, column: number, ids: number[]) => void;
|
|
}) => {
|
|
const [dragged, setDragged] = useState<number | null>(null);
|
|
const [order, setOrder] = useState<CanvasComponent[] | null>(null);
|
|
const pending = useRef<CanvasComponent[] | null>(null);
|
|
const columns = section.layout === 'two' ? 2 : 1;
|
|
|
|
const blocksIn = (column: number) =>
|
|
(order ?? section.components).filter((block) => block.column === column);
|
|
|
|
const moveOver = (event: React.DragEvent, over: CanvasComponent) => {
|
|
if (dragged === null) return;
|
|
const all = order ?? section.components;
|
|
const from = all.findIndex((one) => one.id === dragged);
|
|
// Within a column only — see the note above. A drop on the other column is simply not accepted.
|
|
if (from < 0 || all[from]!.column !== over.column) return;
|
|
|
|
event.preventDefault();
|
|
if (dragged === over.id) return;
|
|
|
|
const next = [...all];
|
|
const to = next.findIndex((one) => one.id === over.id);
|
|
next.splice(to, 0, next.splice(from, 1)[0]!);
|
|
pending.current = next;
|
|
setOrder(next);
|
|
};
|
|
|
|
const commit = (column: number) => {
|
|
const next = pending.current;
|
|
setDragged(null);
|
|
pending.current = null;
|
|
// Dropped where it started, or outside the list. Nothing moved, so nothing is written.
|
|
if (!next) return;
|
|
setOrder(null);
|
|
onReorder(section, column, next.filter((one) => one.column === column).map((one) => one.id));
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{Array.from({ length: columns }, (_, column) => (
|
|
<div key={column} className="flex flex-col gap-0.5">
|
|
{blocksIn(column).map((block) => (
|
|
<span
|
|
key={block.id}
|
|
onDragOver={(event) => moveOver(event, block)}
|
|
onDrop={() => commit(column)}
|
|
className={`flex items-center gap-1 ${dragged === block.id ? 'opacity-40' : ''}`}
|
|
>
|
|
<span
|
|
draggable
|
|
onDragStart={(event) => {
|
|
setDragged(block.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(block.id));
|
|
}}
|
|
onDragEnd={() => commit(column)}
|
|
className="shrink-0 cursor-grab pl-1 text-muted-foreground/50 active:cursor-grabbing"
|
|
aria-label="Reorder"
|
|
>
|
|
<GripVertical className="size-3.5" />
|
|
</span>
|
|
|
|
<Link
|
|
to={href('pages', slug, String(section.id), String(block.id))}
|
|
replace
|
|
className="min-w-0 flex-1 truncate py-0.5 text-[11px] text-muted-foreground hover:text-foreground"
|
|
>
|
|
{CHOICES.find((choice) => choice.kind === block.kind)?.label ?? block.kind}
|
|
</Link>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 shrink-0 px-1 text-destructive"
|
|
disabled={busy}
|
|
onClick={() => onDelete(block)}
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</span>
|
|
))}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 justify-start px-1 text-[11px] text-muted-foreground"
|
|
disabled={busy}
|
|
onClick={() => onAdd(section, column)}
|
|
>
|
|
<Plus className="size-3" />
|
|
{columns === 2 ? (column === 0 ? 'Add left' : 'Add right') : 'Add block'}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|