diff --git a/web/sections/BandBlocks.tsx b/web/sections/BandBlocks.tsx new file mode 100644 index 0000000..167369d --- /dev/null +++ b/web/sections/BandBlocks.tsx @@ -0,0 +1,140 @@ +import { useRef, useState } from 'react'; +import { GripVertical, Plus, Trash2 } from 'lucide-react'; +import { Link } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { href } from '../routes'; +import { CHOICES } from './AddComponentDialog'; +import type { CanvasComponent, CanvasSection } from '../canvas'; + +/** + * What is in one band, column by column. + * + * ── One list, two places ── + * + * The page's structure nests this under every band, and the band's own editor shows just its own. + * They are the same list of the same things with the same actions, so they are the same component — + * the galleries panel taught that lesson once already. + * + * ── Columns, because adding needs one ── + * + * A block goes IN a column, so a flat list cannot express adding one. A one-column band shows a single + * button and never has to ask a question it has no answer to; a two-column band shows Add left and Add + * right. + * + * ── Reorder is within a column ── + * + * The write sends the column alongside the ids, and the query sets both — so dragging a block from one + * column to the other is the same call, on the day the interface offers it. It does not yet: a list + * per column has no gesture for crossing between them, and inventing one here would be guessing. + */ +export const BandBlocks = ({ + slug, + section, + busy, + onAdd, + onDelete, + onReorder, +}: { + slug: string; + section: CanvasSection; + busy: boolean; + onAdd: (section: CanvasSection, column: number) => void; + onDelete: (block: CanvasComponent) => void; + onReorder: (section: CanvasSection, column: number, ids: number[]) => void; +}) => { + const [dragged, setDragged] = useState(null); + const [order, setOrder] = useState(null); + const pending = useRef(null); + const columns = section.layout === 'two' ? 2 : 1; + + const blocksIn = (column: number) => + (order ?? section.components).filter((block) => block.column === column); + + const moveOver = (event: React.DragEvent, over: CanvasComponent) => { + if (dragged === null) return; + const all = order ?? section.components; + const from = all.findIndex((one) => one.id === dragged); + // Within a column only — see the note above. A drop on the other column is simply not accepted. + if (from < 0 || all[from]!.column !== over.column) return; + + event.preventDefault(); + if (dragged === over.id) return; + + const next = [...all]; + const to = next.findIndex((one) => one.id === over.id); + next.splice(to, 0, next.splice(from, 1)[0]!); + pending.current = next; + setOrder(next); + }; + + const commit = (column: number) => { + 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(section, column, next.filter((one) => one.column === column).map((one) => one.id)); + }; + + return ( +
+ {Array.from({ length: columns }, (_, column) => ( +
+ {blocksIn(column).map((block) => ( + moveOver(event, block)} + onDrop={() => commit(column)} + className={`flex items-center gap-1 ${dragged === block.id ? 'opacity-40' : ''}`} + > + { + setDragged(block.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(block.id)); + }} + onDragEnd={() => commit(column)} + className="shrink-0 cursor-grab pl-1 text-muted-foreground/50 active:cursor-grabbing" + aria-label="Reorder" + > + + + + + {CHOICES.find((choice) => choice.kind === block.kind)?.label ?? block.kind} + + + + + ))} + + +
+ ))} +
+ ); +}; diff --git a/web/sections/BandSettings.tsx b/web/sections/BandSettings.tsx index fabd04f..0777866 100644 --- a/web/sections/BandSettings.tsx +++ b/web/sections/BandSettings.tsx @@ -23,11 +23,14 @@ export const BandSettings = ({ pageId, band, onSaved, + blocks, }: { pageId: number; band: { id: number; name: string; title?: string | null }; /** The builder re-reads its sections: the nav's row is this name, and the page draws this title. */ onSaved: () => void; + /** This band's blocks — `BandBlocks`, built by the panel that has the handlers. */ + blocks?: React.ReactNode; }) => { const client = useClient(); const clientRef = useRef(client); @@ -92,6 +95,14 @@ export const BandSettings = ({ {saved &&

Saved

} {error &&

{error}

} + + {/* + What is in this band, the same list the page's structure nests under every band. + + A band selected and its contents not shown would mean going back out to the page to add a block + to the thing you are already looking at. + */} + {blocks} ); }; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index da1f6f4..7f1718a 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -40,6 +40,7 @@ import { GalleryItems } from './GalleryItems'; import { PartnersEditor } from './PartnersEditor'; import { BlockMedia } from './BlockMedia'; import { PageStructure } from './PageStructure'; +import { BandBlocks } from './BandBlocks'; /** * The shapes a photograph block may take. @@ -524,6 +525,24 @@ export const PageBuilderPanel = () => { const reorderRef = useRef<(ids: number[]) => Promise>(async () => {}); reorderRef.current = reorderSections; + /** The order of one column's blocks. The column travels with the ids — see the site's query. */ + const reorderBlocks = async (section: CanvasSection, column: number, ids: number[]) => { + if (!page) return; + setError(null); + try { + await clientRef.current.put( + `/fofos-admin/pages/${page.id}/sections/${section.id}/components/order`, + { column, ids }, + ); + setRendered((n) => n + 1); + } catch (failure) { + setError(readableError(failure)); + } + }; + + const reorderBlocksRef = useRef(async () => {}); + reorderBlocksRef.current = reorderBlocks; + const setContent = async (component: CanvasComponent, content: Record) => { const owner = sections.find((section) => section.components.some((one) => one.id === component.id)); if (!page || !owner) return; @@ -899,7 +918,21 @@ export const PageBuilderPanel = () => { const band = !piece && part ? sections.find((section) => String(section.id) === part) : null; if (band && page) { setEditorRef.current( - setRendered((n) => n + 1)} />, + setRendered((n) => n + 1)} + blocks={ + handlersRef.current.addComponent(one, column)} + onDelete={(one) => handlersRef.current.removeComponent(one)} + onReorder={(one, column, ids) => void reorderBlocksRef.current(one, column, ids)} + /> + } + />, ); return; } @@ -922,6 +955,7 @@ export const PageBuilderPanel = () => { onReorder={(ids) => void reorderRef.current(ids)} onAddComponent={(section, column) => handlersRef.current.addComponent(section, column)} onDeleteComponent={(block) => handlersRef.current.removeComponent(block)} + onReorderComponents={(section, column, ids) => void reorderBlocksRef.current(section, column, ids)} />, ); return; diff --git a/web/sections/PageStructure.tsx b/web/sections/PageStructure.tsx index d5e285a..465d886 100644 --- a/web/sections/PageStructure.tsx +++ b/web/sections/PageStructure.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Link } from 'react-router'; import { href } from '../routes'; import type { CanvasComponent, CanvasSection } from '../canvas'; -import { CHOICES } from './AddComponentDialog'; +import { BandBlocks } from './BandBlocks'; /** * The page, as a list of its bands. @@ -34,6 +34,7 @@ export const PageStructure = ({ onReorder, onAddComponent, onDeleteComponent, + onReorderComponents, }: { /** For the links: pressing a band still takes you to it. */ slug: string; @@ -45,6 +46,7 @@ export const PageStructure = ({ /** 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(null); const [order, setOrder] = useState(null); @@ -128,53 +130,15 @@ export const PageStructure = ({ - {/* - The band's columns, and what is in each. - - Nested rather than listed flat, because ADDING a block means choosing which column it - goes in — and a one-column band should not have to answer a question it has no answer - to. So a band with one column shows one button and a band with two shows two. - - The blocks themselves are links, like everywhere else: pressing one opens its editor. - */} -
- {Array.from({ length: section.layout === 'two' ? 2 : 1 }, (_, column) => ( -
- {section.components - .filter((block) => block.column === column) - .map((block) => ( - - - {CHOICES.find((choice) => choice.kind === block.kind)?.label ?? block.kind} - - - - ))} - - -
- ))} +
+
))}