Selecting a band showed its name and title and nothing about what was in it — so adding a block to the thing you were looking at meant going back out to the page. BandBlocks is that list, used by the band's editor and nested under every band in the page's structure. Same list, same actions, one component; the galleries panel taught that lesson once already. Blocks reorder within a column now. Across columns is the same call — the write carries the column — but a list per column has no gesture for crossing between them, and inventing one here would be guessing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
153 lines
5.6 KiB
TypeScript
153 lines
5.6 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { GripVertical, Plus, Trash2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Link } from 'react-router';
|
|
import { href } from '../routes';
|
|
import type { CanvasComponent, CanvasSection } from '../canvas';
|
|
import { BandBlocks } from './BandBlocks';
|
|
|
|
/**
|
|
* The page, as a list of its bands.
|
|
*
|
|
* ── Why the editor and not the nav ──
|
|
*
|
|
* The nav lists the same bands and is for GOING somewhere: press one and the preview scrolls to it.
|
|
* This is for changing what the page is made of — adding a band, removing one, putting them in a
|
|
* different order. Two different verbs on the same nouns, and mixing them would mean a nav row that
|
|
* sometimes navigates and sometimes deletes.
|
|
*
|
|
* It replaced a bar painted onto the page itself, which carried the same three actions and made the
|
|
* preview stop being a preview. See the note in canvas.ts.
|
|
*
|
|
* ── The gesture is the one used everywhere else ──
|
|
*
|
|
* Grip on the left, the row is the drop target, the list rearranges as the pointer crosses it, one
|
|
* write at the end carrying every id. Pages, gallery items and partners all do this; a fourth way to
|
|
* reorder something would be a fourth thing to learn.
|
|
*/
|
|
export const PageStructure = ({
|
|
slug,
|
|
sections,
|
|
busy,
|
|
onAdd,
|
|
onDelete,
|
|
onReorder,
|
|
onAddComponent,
|
|
onDeleteComponent,
|
|
onReorderComponents,
|
|
}: {
|
|
/** For the links: pressing a band still takes you to it. */
|
|
slug: string;
|
|
sections: CanvasSection[];
|
|
busy: boolean;
|
|
onAdd: () => void;
|
|
onDelete: (section: CanvasSection) => void;
|
|
onReorder: (ids: number[]) => void;
|
|
/** A block needs a COLUMN to go in, which is the whole reason blocks are nested here. */
|
|
onAddComponent: (section: CanvasSection, column: number) => void;
|
|
onDeleteComponent: (component: CanvasComponent) => void;
|
|
onReorderComponents: (section: CanvasSection, column: number, ids: number[]) => void;
|
|
}) => {
|
|
const [dragged, setDragged] = useState<number | null>(null);
|
|
const [order, setOrder] = useState<CanvasSection[] | null>(null);
|
|
const pending = useRef<CanvasSection[] | null>(null);
|
|
|
|
// The live order while dragging, or the real one. Reset whenever the page hands over a new list.
|
|
const rows = order ?? sections;
|
|
|
|
const moveOver = (event: React.DragEvent, overId: number) => {
|
|
if (dragged === null) return;
|
|
event.preventDefault();
|
|
if (dragged === overId) return;
|
|
|
|
const next = [...rows];
|
|
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]!);
|
|
pending.current = next;
|
|
setOrder(next);
|
|
};
|
|
|
|
const commit = () => {
|
|
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(next.map((one) => one.id));
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-xs font-medium">Sections</span>
|
|
|
|
<ul className="flex flex-col gap-1">
|
|
{rows.map((section) => (
|
|
<li
|
|
key={section.id}
|
|
onDragOver={(event) => moveOver(event, section.id)}
|
|
onDrop={commit}
|
|
className={`flex flex-col rounded border border-border/60 ${
|
|
dragged === section.id ? 'opacity-40' : ''
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span
|
|
draggable
|
|
onDragStart={(event) => {
|
|
setDragged(section.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(section.id));
|
|
}}
|
|
onDragEnd={commit}
|
|
className="shrink-0 cursor-grab py-1.5 pl-1.5 text-muted-foreground/60 active:cursor-grabbing"
|
|
aria-label={`Reorder ${section.name}`}
|
|
>
|
|
<GripVertical className="size-4" />
|
|
</span>
|
|
|
|
{/* Still a link: pressing a band is how you get to it, here as in the nav. */}
|
|
<Link to={href('pages', slug, String(section.id))} replace className="min-w-0 flex-1 py-1.5">
|
|
<span className="block truncate text-xs">{section.name}</span>
|
|
<span className="block truncate text-[10px] text-muted-foreground">
|
|
{section.layout === 'two' ? 'Two columns' : 'One column'} ·{' '}
|
|
{section.components.length} block{section.components.length === 1 ? '' : 's'}
|
|
</span>
|
|
</Link>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 shrink-0 px-1.5 text-destructive"
|
|
disabled={busy}
|
|
onClick={() => onDelete(section)}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="border-t border-border/60 px-2 py-1.5">
|
|
<BandBlocks
|
|
slug={slug}
|
|
section={section}
|
|
busy={busy}
|
|
onAdd={onAddComponent}
|
|
onDelete={onDeleteComponent}
|
|
onReorder={onReorderComponents}
|
|
/>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<Button variant="outline" size="sm" className="h-8 self-start text-xs" disabled={busy} onClick={onAdd}>
|
|
<Plus className="size-3.5" /> Add section…
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|