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>
This commit is contained in:
2026-09-01 22:00:35 +01:00
co-authored by Claude Opus 5
parent 26e87df6e5
commit eb8da779a7
2 changed files with 169 additions and 1 deletions
+38 -1
View File
@@ -39,6 +39,7 @@ import { BlockSettings } from './BlockSettings';
import { GalleryItems } from './GalleryItems'; import { GalleryItems } from './GalleryItems';
import { PartnersEditor } from './PartnersEditor'; import { PartnersEditor } from './PartnersEditor';
import { BlockMedia } from './BlockMedia'; import { BlockMedia } from './BlockMedia';
import { PageStructure } from './PageStructure';
/** /**
* The shapes a photograph block may take. * The shapes a photograph block may take.
@@ -508,6 +509,21 @@ export const PageBuilderPanel = () => {
* there alongside what it is changing. That is the contract for a jsonb column somebody can put * there alongside what it is changing. That is the contract for a jsonb column somebody can put
* anything in: a merge would need the server to know which keys are meaningful, and it does not. * anything in: a merge would need the server to know which keys are meaningful, and it does not.
*/ */
/** Every id, in order — which is what makes two bands claiming a position impossible. */
const reorderSections = async (ids: number[]) => {
if (!page) return;
setError(null);
try {
await clientRef.current.put(`/fofos-admin/pages/${page.id}/sections/order`, { ids });
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
const reorderRef = useRef<(ids: number[]) => Promise<void>>(async () => {});
reorderRef.current = reorderSections;
const setContent = async (component: CanvasComponent, content: Record<string, unknown>) => { const setContent = async (component: CanvasComponent, content: Record<string, unknown>) => {
const owner = sections.find((section) => section.components.some((one) => one.id === component.id)); const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
if (!page || !owner) return; if (!page || !owner) return;
@@ -888,8 +904,29 @@ export const PageBuilderPanel = () => {
return; return;
} }
/*
Nothing selected: the page itself, as the list of bands it is made of.
Last, after the band and the block, because those are things you opened deliberately. This is
what is there when you have not — and it is where adding, removing and reordering a band live,
now that the bars painted onto the page are gone.
*/
if (page) {
setEditorRef.current(
<PageStructure
slug={slug ?? ''}
sections={sections}
busy={creating}
onAdd={() => handlersRef.current.add()}
onDelete={(section) => handlersRef.current.remove(section.id)}
onReorder={(ids) => void reorderRef.current(ids)}
/>,
);
return;
}
setEditorRef.current(null); setEditorRef.current(null);
}, [spacing, editing, creating, device, steps, part, piece, sections, page]); }, [spacing, editing, creating, device, steps, part, piece, sections, page, slug]);
// Cleared on unmount, or the panel goes on offering an editor for a page nobody is looking at. // Cleared on unmount, or the panel goes on offering an editor for a page nobody is looking at.
useEffect(() => () => setEditorRef.current(null), []); useEffect(() => () => setEditorRef.current(null), []);
+131
View File
@@ -0,0 +1,131 @@
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>
);
};