Files
pastilhasandClaude Opus 5 a0720b5160 name the columns, not the buttons
A two-column band listed its blocks unlabelled and put the side on the add
buttons. A band whose only block was on the left therefore read as a band with
nothing on the left and two ways to fix it.

The side belongs on the group, which is the thing that has one. Left and Right
head their own lists behind a rule; the buttons say what they do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:18:51 +01:00

158 lines
6.1 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 NAMES its columns. It used to leave them unlabelled and put the side on the
* buttons — "Add left", "Add right" — and that reads as a flat list of blocks with two odd buttons
* under it: a band whose only block was on the left looked like a band with nothing on the left and
* two ways to fix it. The side belongs on the GROUP, which is the thing that has one; the buttons then
* say what they do, and an empty column is visible as an empty column rather than inferred from the
* absence of something.
*
* ── 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}
// Indented behind a rule when there are two, so where one column ends is something you see
// rather than something you count.
className={`flex flex-col gap-0.5 ${columns === 2 ? 'border-l border-border/60 pl-1.5' : ''}`}
>
{columns === 2 && (
<span className="px-1 pt-0.5 text-[10px] font-medium tracking-wide text-muted-foreground/60 uppercase">
{column === 0 ? 'Left' : 'Right'}
</span>
)}
{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" />
Add block
</Button>
</div>
))}
</div>
);
};