Files
pastilhasandClaude Opus 5 6e33e89e2c finish the carousel rename in the panels
Two of these were live: PageBuilderPanel still compared kind === 'gallery' in
two places, so a carousel block's editor and its Choose button were both dead
branches. The type checker could not see them — both compare against string.

The rest is copy somebody reads: "Change gallery", "Name a new gallery",
"Reading the gallery", "Add to this gallery", Home's Gallery heading, and the
"Create Gallery" file action, now "Carousel from folder".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 18:06:06 +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, carousel 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>
);
};