A dashboard now carries the canvas it is drawn for (canvas_width / canvas_height, presets plus two numbers in the settings panel). Editor and wall panel render that surface at its true pixel size and scale it to fit, so a side panel opening changes only the scale — never the arrangement being made. With the width and column count known the dot pitch is exact, (width + gap) / columns by row height + gap, so a dot sits where every widget corner snaps. The wall panel shows no dots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
262 lines
7.7 KiB
TypeScript
262 lines
7.7 KiB
TypeScript
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<HTMLDivElement>(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 (
|
|
<div ref={ref} className="relative size-full overflow-hidden">
|
|
{/* Measured first: a guessed scale would place the whole panel once and
|
|
then move it. */}
|
|
{scale > 0 ? (
|
|
<div
|
|
className={cn("absolute overflow-hidden", dots && "dot-canvas")}
|
|
data-testid="canvas-surface"
|
|
style={
|
|
{
|
|
width,
|
|
height,
|
|
left: (box.width - width * scale) / 2,
|
|
top: (box.height - height * scale) / 2,
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: "top left",
|
|
// One dot per cell corner. The column pitch the grid snaps to is
|
|
// (width + gap) / columns; a row is one row plus the gap.
|
|
"--dot-x": `${(width + GRID_GAP) / columnsOf(dashboard)}px`,
|
|
"--dot-y": `${ROW_PITCH}px`,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
{children(scale)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function placement(widget: WidgetDef): Placement {
|
|
const layout = (widget.layout ?? {}) as Record<string, Placement>
|
|
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 className="grid gap-3">
|
|
{section.title ? (
|
|
<h2 className="text-sm font-medium text-muted-foreground">
|
|
{section.title}
|
|
</h2>
|
|
) : null}
|
|
<div
|
|
className={cn("widget-grid", className)}
|
|
data-placed={isPlaced(widgets) || undefined}
|
|
style={{ "--widget-cols": columns } as React.CSSProperties}
|
|
>
|
|
{widgets.map((widget) => (
|
|
<div
|
|
key={widget.id}
|
|
style={widgetStyle(widget, columns)}
|
|
className="widget-cell"
|
|
>
|
|
{renderWidget ? (
|
|
renderWidget(widget)
|
|
) : (
|
|
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
|
|
<WidgetBody widget={widget} dashboard={dashboard} />
|
|
</WidgetFrame>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<p className="text-sm text-muted-foreground">
|
|
This dashboard has no pages yet.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
const empty = sectionsOf(page).every(
|
|
(section) => widgetsOf(section).length === 0,
|
|
)
|
|
if (empty) {
|
|
return (
|
|
<p
|
|
className="text-sm text-muted-foreground"
|
|
data-testid="dashboard-empty"
|
|
>
|
|
Nothing on this page yet. Edit it to add a widget.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-6">
|
|
{sectionsOf(page).map((section) => (
|
|
<SectionGrid
|
|
key={section.id}
|
|
section={section}
|
|
dashboard={dashboard.name}
|
|
columns={columnsOf(dashboard)}
|
|
renderWidget={renderWidget}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|