diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 41bfe22..2c47456 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -39,6 +39,7 @@ import { BlockSettings } from './BlockSettings'; import { GalleryItems } from './GalleryItems'; import { PartnersEditor } from './PartnersEditor'; import { BlockMedia } from './BlockMedia'; +import { PageStructure } from './PageStructure'; /** * 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 * 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>(async () => {}); + reorderRef.current = reorderSections; + const setContent = async (component: CanvasComponent, content: Record) => { const owner = sections.find((section) => section.components.some((one) => one.id === component.id)); if (!page || !owner) return; @@ -888,8 +904,29 @@ export const PageBuilderPanel = () => { 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( + handlersRef.current.add()} + onDelete={(section) => handlersRef.current.remove(section.id)} + onReorder={(ids) => void reorderRef.current(ids)} + />, + ); + return; + } + 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. useEffect(() => () => setEditorRef.current(null), []); diff --git a/web/sections/PageStructure.tsx b/web/sections/PageStructure.tsx new file mode 100644 index 0000000..8524977 --- /dev/null +++ b/web/sections/PageStructure.tsx @@ -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(null); + const [order, setOrder] = useState(null); + const pending = useRef(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 ( +
+ Sections + +
    + {rows.map((section) => ( +
  • moveOver(event, section.id)} + onDrop={commit} + className={`flex items-center gap-1 rounded border border-border/60 ${ + dragged === section.id ? 'opacity-40' : '' + }`} + > + { + 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}`} + > + + + + {/* Still a link: pressing a band is how you get to it, here as in the nav. */} + + {section.name} + + {section.layout === 'two' ? 'Two columns' : 'One column'} ·{' '} + {section.components.length} block{section.components.length === 1 ? '' : 's'} + + + + +
  • + ))} +
+ + +
+ ); +};