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>
This commit is contained in:
2026-09-01 22:44:27 +01:00
co-authored by Claude Opus 5
parent eb8da779a7
commit a922c6fda2
2 changed files with 61 additions and 2 deletions
+2
View File
@@ -920,6 +920,8 @@ export const PageBuilderPanel = () => {
onAdd={() => handlersRef.current.add()} onAdd={() => handlersRef.current.add()}
onDelete={(section) => handlersRef.current.remove(section.id)} onDelete={(section) => handlersRef.current.remove(section.id)}
onReorder={(ids) => void reorderRef.current(ids)} onReorder={(ids) => void reorderRef.current(ids)}
onAddComponent={(section, column) => handlersRef.current.addComponent(section, column)}
onDeleteComponent={(block) => handlersRef.current.removeComponent(block)}
/>, />,
); );
return; return;
+59 -2
View File
@@ -3,7 +3,8 @@ import { GripVertical, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { href } from '../routes'; import { href } from '../routes';
import type { CanvasSection } from '../canvas'; import type { CanvasComponent, CanvasSection } from '../canvas';
import { CHOICES } from './AddComponentDialog';
/** /**
* The page, as a list of its bands. * The page, as a list of its bands.
@@ -31,6 +32,8 @@ export const PageStructure = ({
onAdd, onAdd,
onDelete, onDelete,
onReorder, onReorder,
onAddComponent,
onDeleteComponent,
}: { }: {
/** For the links: pressing a band still takes you to it. */ /** For the links: pressing a band still takes you to it. */
slug: string; slug: string;
@@ -39,6 +42,9 @@ export const PageStructure = ({
onAdd: () => void; onAdd: () => void;
onDelete: (section: CanvasSection) => void; onDelete: (section: CanvasSection) => void;
onReorder: (ids: number[]) => 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 [dragged, setDragged] = useState<number | null>(null);
const [order, setOrder] = useState<CanvasSection[] | null>(null); const [order, setOrder] = useState<CanvasSection[] | null>(null);
@@ -82,10 +88,11 @@ export const PageStructure = ({
key={section.id} key={section.id}
onDragOver={(event) => moveOver(event, section.id)} onDragOver={(event) => moveOver(event, section.id)}
onDrop={commit} onDrop={commit}
className={`flex items-center gap-1 rounded border border-border/60 ${ className={`flex flex-col rounded border border-border/60 ${
dragged === section.id ? 'opacity-40' : '' dragged === section.id ? 'opacity-40' : ''
}`} }`}
> >
<div className="flex items-center gap-1">
<span <span
draggable draggable
onDragStart={(event) => { onDragStart={(event) => {
@@ -119,6 +126,56 @@ export const PageStructure = ({
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </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> </li>
))} ))}
</ul> </ul>