import { useEffect, useRef, useState } from "react" import type { DashboardDef_Output, PageDef_Output, Placement, SectionDef_Output, WidgetDef, } from "@/client" import { cn } from "@/lib/utils" import "./dashboard.css" import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets" export type Dashboard = DashboardDef_Output /** How many columns a wall panel is cut into, if the document does not say. */ export const DEFAULT_COLUMNS = 12 /** What a grid size setting may be set to; a panel is matched to one of these. */ export const COLUMN_CHOICES = [6, 8, 12, 16, 24] /** The panel a dashboard is drawn for, if the document does not say. */ export const DEFAULT_CANVAS = { width: 1920, height: 1080 } /** Panels worth a name; anything else is typed in as two numbers. */ export const CANVAS_PRESETS = [ { label: '7" panel', width: 1024, height: 600 }, { label: '10" tablet', width: 1280, height: 800 }, { label: "Full HD", width: 1920, height: 1080 }, { label: '10" tablet, portrait', width: 800, height: 1280 }, { label: "Full HD, portrait", width: 1080, height: 1920 }, ] /** One grid row, in pixels — the unit widget heights are multiples of. */ export const ROW_HEIGHT = 80 /** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */ export const GRID_GAP = 12 /** Distance between two row origins — what a widget's `y` is counted in. */ export const ROW_PITCH = ROW_HEIGHT + GRID_GAP /** * The generated client marks every list optional, because the server fills * them in. These three keep that from spreading through the components. */ export const pagesOf = (dashboard: DashboardDef_Output) => dashboard.pages ?? [] export const sectionsOf = (page: PageDef_Output) => page.sections ?? [] export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? [] export const columnsOf = (dashboard: Dashboard) => dashboard.columns || DEFAULT_COLUMNS export const canvasOf = (dashboard: Dashboard) => ({ width: dashboard.canvas_width || DEFAULT_CANVAS.width, height: dashboard.canvas_height || DEFAULT_CANVAS.height, }) /** How many rows of the grid fit in the canvas; the rest is off the panel. */ export const rowsOf = (dashboard: Dashboard) => Math.max(1, Math.floor((canvasOf(dashboard).height + GRID_GAP) / ROW_PITCH)) /** * The dashboard's own surface: exactly the panel's pixel size, scaled to fit * whatever room is left around it. * * Scaling rather than reflowing is the point. A side panel opening, or a * narrower screen, changes only the scale — the arrangement being designed, * and the dot grid under it, stay the layout the panel will actually show. */ export function CanvasSurface({ dashboard, dots, children, }: { dashboard: Dashboard /** Draw the placement grid. A wall panel is not being arranged, so: no. */ dots?: boolean children: (scale: number) => React.ReactNode }) { const ref = useRef(null) const [box, setBox] = useState({ width: 0, height: 0 }) useEffect(() => { const element = ref.current if (!element) return const observer = new ResizeObserver(([entry]) => setBox({ width: entry.contentRect.width, height: entry.contentRect.height, }), ) observer.observe(element) return () => observer.disconnect() }, []) const { width, height } = canvasOf(dashboard) const scale = Math.min(box.width / width, box.height / height) return (
{/* Measured first: a guessed scale would place the whole panel once and then move it. */} {scale > 0 ? (
{children(scale)}
) : null}
) } export function placement(widget: WidgetDef): Placement { const layout = (widget.layout ?? {}) as Record return layout.lg ?? layout.md ?? layout.sm ?? {} } /** * A widget's box, as plain CSS grid. * * View mode never loads a grid library: a wall panel that only displays should * not pay for the code that lets someone drag things around. */ export function widgetStyle( widget: WidgetDef, columns = DEFAULT_COLUMNS, ): React.CSSProperties { const { x = 0, y = 0, w = 3, h = 2 } = placement(widget) const width = Math.min(columns, Math.max(1, w)) return { "--x": Math.min(columns - width, Math.max(0, x)) + 1, "--y": Math.max(0, y) + 1, "--w": width, "--h": Math.max(1, h), } as React.CSSProperties } /** * Has anyone actually arranged this dashboard? * * Before drag-and-drop every widget was written at 0,0, so honouring the * stored position would pile the whole page onto one cell. */ export const isPlaced = (widgets: WidgetDef[]) => widgets.some((widget) => { const { x = 0, y = 0 } = placement(widget) return x > 0 || y > 0 }) export function SectionGrid({ section, dashboard, columns = DEFAULT_COLUMNS, renderWidget, className, }: { section: SectionDef_Output /** Which dashboard this is, so an input widget can name itself. */ dashboard: string columns?: number renderWidget?: (widget: WidgetDef) => React.ReactNode className?: string }) { const widgets = widgetsOf(section) return (
{section.title ? (

{section.title}

) : null}
{widgets.map((widget) => (
{renderWidget ? ( renderWidget(widget) ) : ( )}
))}
) } export function DashboardView({ dashboard, pageId, renderWidget, }: { dashboard: Dashboard pageId?: string renderWidget?: (widget: WidgetDef) => React.ReactNode }) { const pages = pagesOf(dashboard) const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0] if (!page) { return (

This dashboard has no pages yet.

) } const empty = sectionsOf(page).every( (section) => widgetsOf(section).length === 0, ) if (empty) { return (

Nothing on this page yet. Edit it to add a widget.

) } return (
{sectionsOf(page).map((section) => ( ))}
) }