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>
153 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
};
|