Files
fofos-admin/web/sections/BandBlocks.tsx
T
pastilhasandClaude Opus 5 15042ccc76 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>
2026-09-04 18:01:15 +01:00

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>
);
};