Files
fofos-admin/web/sections/PageStructure.tsx
T
pastilhasandClaude Opus 5 a922c6fda2 blocks nest under their band, with a button per column
Adding a block means choosing which column it goes in, so a flat list cannot
express it. A one-column band shows one button and a two-column band shows two —
and a band with one column never has to answer a question it has no answer to.

The blocks are links, like everywhere else: pressing one opens its editor.

That is the last of the four actions the painted-on bars used to carry.

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

189 lines
7.5 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 { CHOICES } from './AddComponentDialog';
/**
* 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,
}: {
/** 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;
}) => {
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>
{/*
The band's columns, and what is in each.
Nested rather than listed flat, because ADDING a block means choosing which column it
goes in — and a one-column band should not have to answer a question it has no answer
to. So a band with one column shows one button and a band with two shows two.
The blocks themselves are links, like everywhere else: pressing one opens its editor.
*/}
<div className="flex flex-col gap-1 border-t border-border/60 px-2 py-1.5">
{Array.from({ length: section.layout === 'two' ? 2 : 1 }, (_, column) => (
<div key={column} className="flex flex-col gap-0.5">
{section.components
.filter((block) => block.column === column)
.map((block) => (
<span key={block.id} className="flex items-center gap-1">
<Link
to={href('pages', slug, String(section.id), String(block.id))}
replace
className="min-w-0 flex-1 truncate py-0.5 pl-3 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={() => onDeleteComponent(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={() => onAddComponent(section, column)}
>
<Plus className="size-3" />
{section.layout === 'two' ? (column === 0 ? 'Add left' : 'Add right') : 'Add block'}
</Button>
</div>
))}
</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>
);
};