"use client"; import { Fragment, useState } from "react"; import type { WidgetConfig } from "../../lib/config"; import { MAX_COLUMNS, MAX_ROWS, addCell, insertAt, moveToCell, moveToRow, moveWithin, reconcileLayout, removeCell, setCellFlow, setCellSpan, setColumnCount, setRowCount, setRowSize, } from "../../lib/placement"; import { SECTIONS, SECTION_BY_ID, type SectionId } from "../../lib/sections"; import { SectionCard } from "./SectionCard"; import { SectionMini } from "./SectionMini"; // The floorplan: a scale model of the widget, laid out in the same grid it will // render in — the same tracks, the same rows, the same spans. Drag a card to // move it; the tray underneath holds the sections that are switched off. // // The board's one bold element is the LEFT RAIL, one control per row drawn as // its height: a hairline box for `auto`, a tall box with a spring mark for // `fill`. That is where the dimension the columns model never had actually // lives, so it is the one thing here allowed to look like something. Everything // else stays hairlines on `surface` and `card`. // // Native HTML5 drag-and-drop, deliberately — it costs no dependency, and the // arrow buttons on every card cover keyboard use (and touch, which HTML5 drag // does not support) so nothing here is mouse-only. // Below this a track is too narrow for the strips inside it to read. const NARROW_PX = 150; // Literal classes, because Tailwind reads the source. The `@max-[24rem]` // overrides are on the BOARD's own container query, not the viewport's, so the // board folds identically inside the widget's ~320px menu overlay and on the // builder page. const BOARD_COLS: Record = { 1: "grid-cols-1", 2: "grid-cols-2", 3: "grid-cols-3", 4: "grid-cols-4", 5: "grid-cols-5", 6: "grid-cols-6", }; const BOARD_SPAN: Record = { 1: "col-span-1", 2: "col-span-2", 3: "col-span-3", 4: "col-span-4", 5: "col-span-5", 6: "col-span-6", }; const STACKED = "@max-[24rem]:grid-cols-1"; const STACKED_CELL = "@max-[24rem]:col-span-1"; const CHIP = "flex h-6 min-w-6 items-center justify-center rounded border border-border px-1 text-xs font-medium text-muted-foreground hover:bg-muted disabled:opacity-40"; export function LayoutBoard({ config, onChange, previewWidth, }: { config: WidgetConfig; onChange: (patch: Partial) => void; // The width the widget will actually be. Absent inside the widget's own menu, // where the px readouts are noise in a 320px overlay. previewWidth?: number; }) { const [draggingId, setDraggingId] = useState(null); const [drop, setDrop] = useState<{ row: number; cell: number; index: number; } | null>(null); const [expanded, setExpanded] = useState([]); const layout = config.layout; const off = SECTIONS.filter((s) => !s.enabled(config)); // The widget's padding (p-2 either side) and the gaps between tracks come out // of the width before it is split, so this is the real number. const trackPx = previewWidth === undefined ? null : Math.round( (previewWidth - 16 - (layout.cols - 1) * 12) / layout.cols, ); function toggleExpanded(id: SectionId) { setExpanded((e) => (e.includes(id) ? e.filter((x) => x !== id) : [...e, id])); } function clearDrag() { setDraggingId(null); setDrop(null); } // Put a section at an explicit slot, switching it on first if it was in the // tray. Both halves go out as one patch so the layout is never briefly // inconsistent with the flags. function place(id: SectionId, row: number, cell: number, index: number) { const def = SECTION_BY_ID[id]; const flagPatch = def.enabled(config) ? {} : def.setEnabled(true); const nextFlags = { ...config, ...flagPatch }; const base = reconcileLayout(layout, nextFlags); onChange({ ...flagPatch, layout: insertAt(base, id, row, cell, index) }); } // Where a drop at this pointer position would land: the first card whose // midpoint is below the cursor, or the end of the cell. function indexFromPointer(cellEl: HTMLElement, clientY: number): number { const cards = Array.from( cellEl.querySelectorAll("[data-section-card]"), ); for (let i = 0; i < cards.length; i++) { const r = cards[i].getBoundingClientRect(); if (clientY < r.top + r.height / 2) return i; } return cards.length; } const lastRow = layout.rows.length - 1; const lastCellOfLastRow = layout.rows[lastRow].cells.length - 1; return (
Columns
{Array.from({ length: MAX_COLUMNS }, (_, i) => i + 1).map((n) => ( ))}

Drag a section to move it, or use the arrows on each card. The rail on the left sets each row's height.

{/* The board itself sits on `surface` so it reads as a canvas the `card` strips are resting on, rather than more of the page. */}
{/* Top ruler: one entry per grid track, carrying the width it gets. */} {trackPx !== null && (
{Array.from({ length: layout.cols }, (_, i) => (
{layout.cols === 1 ? "Width" : `Col ${i + 1}`} {trackPx}px
))}
)}
{layout.rows.map((row, ri) => (
onChange({ layout: setRowSize( layout, ri, row.size === "fill" ? "auto" : "fill", ), }) } />
{row.cells.map((cell, ci) => (
{layout.cols > 1 && ( <> )} {cell.span}/{layout.cols} {row.cells.length > 1 && ( )}
    { if (!draggingId) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const index = indexFromPointer(e.currentTarget, e.clientY); setDrop((d) => d && d.row === ri && d.cell === ci && d.index === index ? d : { row: ri, cell: ci, index }, ); }} onDrop={(e) => { if (!draggingId) return; e.preventDefault(); place( draggingId, ri, ci, indexFromPointer(e.currentTarget, e.clientY), ); clearDrag(); }} className="flex min-h-16 flex-col gap-1.5 rounded p-1" > {cell.sections.map((id, index) => ( onChange({ layout: moveWithin(layout, ri, ci, index, dir), }) } onMoveCell={(dir) => onChange({ layout: moveToCell(layout, ri, ci, index, dir), }) } onMoveRow={(dir) => onChange({ layout: moveToRow(layout, ri, ci, index, dir), }) } onDragStart={() => setDraggingId(id)} onDragEnd={clearDrag} expanded={expanded.includes(id)} onToggleExpanded={() => toggleExpanded(id)} /> ))} {cell.sections.length === 0 && (
  • Empty — drop a section here
  • )}
))}
))}
{/* The rail's foot: the row and cell lifecycle, kept together so adding a place to put something is one gesture away from the rail that sizes it. */}
{ if (!draggingId) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDrop(null); }} onDrop={(e) => { if (!draggingId) return; e.preventDefault(); onChange(SECTION_BY_ID[draggingId].setEnabled(false)); clearDrag(); }} className="flex flex-wrap items-center gap-2 rounded-lg border border-dashed border-border p-2" > Off {off.length === 0 ? ( Every section is on the board. ) : ( // Same checkbox as on the card, deliberately: a section is one // control that changes place, not two that appear and disappear — // which is what makes it findable by name whether it is on or off. off.map((s) => ( )) )} {draggingId !== null && ( Drop here to switch off. )}
); } // The signature control: a row's height, drawn as its height. `auto` is a short // hairline box; `fill` is a tall one carrying a vertical spring mark, which is // the only place on the board that spends any ink. function RowHeightButton({ index, size, onToggle, }: { index: number; size: "auto" | "fill"; onToggle: () => void; }) { const fill = size === "fill"; return ( ); } // The insertion marker. Always in the DOM at every slot so the list doesn't // reflow as it moves between them — only its color changes. function DropLine({ active }: { active: boolean }) { return (
  • ); }