Files
fofos-admin/web/sections/PageStructure.tsx
T
pastilhasandClaude Opus 5 eb8da779a7 the page's structure, in the editor
Add, delete and reorder a band. Those three were reached from a bar painted onto
the page until the overlays went, and have had no door since.

Shown when nothing inside the page is selected — after the band and the block,
because those are things you opened deliberately and this is what is there when
you have not.

The nav lists the same bands and is for GOING somewhere; this is for changing what
the page is made of. Two verbs on the same nouns, and mixing them would mean a nav
row that sometimes navigates and sometimes deletes.

Same reorder gesture as pages, gallery items and partners.

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

132 lines
4.8 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 { CanvasSection } from '../canvas';
/**
* 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,
}: {
/** 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;
}) => {
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 items-center gap-1 rounded border border-border/60 ${
dragged === section.id ? 'opacity-40' : ''
}`}
>
<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>
</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>
);
};