Files
fofos-admin/web/sections/PageStructure.tsx
T
pastilhasandClaude Opus 5 6a86c69890 a band shows its blocks, from one shared list
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>
2026-09-01 22:49:46 +01:00

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