Files
app/frontend/src/components/Dashboard/DashboardView.tsx
T
stroblme 57fd3d1b2c Collapse the button-variant fork and the canvas's duplicate work
LoadingButton kept its own copy of buttonVariants, which had drifted past
`brand` and the icon sizes; it imports Button's now, so the Connect /
Re-pair button can show the loader instead of swapping its own label.

DashboardView mounted a second LookProvider inside the one CanvasSurface
already gives it and dropped `touch` on the way, so every widget on a
touch panel read `useLook().touch` as false. The provider is now mounted
once, by whatever owns the canvas root.

useCanvasRoot resolved the palette twice per render — once for the theme
class, once for the tokens — so useDashboardTheme takes the roles the
caller already has.

The bar assertions in widgets.spec.ts are scoped to the Load tile rather
than the page, which held only because the unbound spare bar draws no
rows.
2026-09-14 15:29:40 +02:00

444 lines
15 KiB
TypeScript

import { motion } from "motion/react"
import { useEffect, useMemo, useRef, useState } from "react"
import type { DashboardDef_Output, Placement, WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import "./dashboard.css"
import {
LockedProvider,
PaletteProvider,
useDashboardBackground,
} from "./settings"
import { useUi } from "./ui"
import { scrolls, showTitle } from "./ui/core/config"
import { LookProvider, useCanvasRoot, useLook } from "./ui/core/look"
import { gridStagger, LOOK } from "./ui/core/motion"
import { WidgetBody, 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.
*
* Named for what they are, not for a diagonal: 1024x600 is sold as a seven-inch
* panel and as a ten-inch one, and the dashboard is laid out in pixels either
* way. A canvas set to the screen it hangs on now draws at 1:1 — the routes
* that show a panel no longer inset it.
*/
export const CANVAS_PRESETS = [
{ label: "Wall 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 },
]
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
export const GRID_GAP = 12
/**
* How far the arrangement keeps from the panel's own edges.
*
* The same as the gap between two widgets, so a tile sits as far from the edge
* of the screen as it does from its neighbour. The ground is not inset with it
* — a background covers the whole panel, and only what is arranged on it is
* held off the edge.
*/
export const GRID_INSET = GRID_GAP
/**
* The column a dashboard-switching rail takes out of the canvas, in canvas
* pixels.
*
* The rail is drawn on the panel rather than beside it — it is part of what
* the screen shows — so it is inside the canvas, scaled with it, wearing the
* dashboard's own look.
*/
export const RAIL_INSET = 72
/**
* The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components.
*/
export const widgetsOf = (dashboard: DashboardDef_Output) =>
dashboard.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,
})
/** One grid row, in pixels, on the canvas the grid was first drawn for. */
const DEFAULT_ROW_HEIGHT = 80
/** A column's own width on that canvas, gaps taken off. */
const DEFAULT_COLUMN_WIDTH =
(DEFAULT_CANVAS.width + GRID_GAP) / DEFAULT_COLUMNS - GRID_GAP
/**
* How much wider a cell is than it is tall.
*
* A column's width already follows the canvas — it is the canvas cut into
* `columns` — while the row height was a fixed 80px, so the same arrangement
* came out as a different picture on every panel: cells taller than wide on a
* 7" one, twice as wide as tall on a 4K one. Holding the proportion instead is
* what makes the canvas worth scaling to fit. The number is the one the
* default canvas already had rather than a new one, so a panel that says
* nothing about its size keeps exactly the 80px rows it had.
*/
const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT
/**
* The part of the canvas an arrangement is laid out in.
*
* The margin off every edge, plus the rail's column when the panel carries
* one: a rail is part of the screen rather than something beside it, so the
* room it takes comes out of the canvas the same way the margin does. Which
* cell each widget sits in never changes — only how big a cell is.
*/
export const areaOf = (dashboard: Dashboard, rail = false) => {
const { width, height } = canvasOf(dashboard)
const left = GRID_INSET + (rail ? RAIL_INSET : 0)
return {
left,
top: GRID_INSET,
width: Math.max(1, width - left - GRID_INSET),
height: Math.max(1, height - 2 * GRID_INSET),
}
}
const columnWidthOf = (dashboard: Dashboard, rail = false) =>
(areaOf(dashboard, rail).width + GRID_GAP) / columnsOf(dashboard) - GRID_GAP
/** One grid row, in pixels — the unit widget heights are multiples of. */
export const rowHeightOf = (dashboard: Dashboard, rail = false) =>
Math.round(columnWidthOf(dashboard, rail) / CELL_ASPECT)
/** Distance between two row origins — what a widget's `y` is counted in. */
export const rowPitchOf = (dashboard: Dashboard, rail = false) =>
rowHeightOf(dashboard, rail) + GRID_GAP
/** How many rows of the grid fit in the canvas; the rest is off the panel. */
export const rowsOf = (dashboard: Dashboard, rail = false) =>
Math.max(
1,
Math.floor(
(areaOf(dashboard, rail).height + GRID_GAP) / rowPitchOf(dashboard, rail),
),
)
/**
* 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.
*
* This box is also exactly what a dashboard's own appearance applies to: it is
* the panel, so its palette, its look and its light or dark all land here and
* leave the shell around it alone. Which is why it states a background at all
* — without one it would borrow whatever it was dropped into.
*/
export function CanvasSurface({
dashboard,
dots,
rail,
touch,
children,
}: {
dashboard: Dashboard
/** Draw the placement grid. A wall panel is not being arranged, so: no. */
dots?: boolean
/** The dashboard-switching rail, drawn on the panel rather than beside it. */
rail?: React.ReactNode
/** Whether the panel this canvas hangs on is touched rather than pointed at. */
touch?: 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)
const root = useCanvasRoot(dashboard, touch)
const area = areaOf(dashboard, Boolean(rail))
// The arrangement, held across the renders that are not about it. This
// component reads the dashboard's own settings, so a bound one wakes it on
// every message the flow sends — and calling a render prop again rebuilds
// every tile. With the callback and the scale both unmoved React skips the
// whole subtree instead.
const content = useMemo(
() => (scale > 0 ? children(scale) : null),
[children, scale],
)
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 ? (
<LookProvider dashboard={dashboard} touch={touch}>
<div
data-look={root["data-look"]}
data-touch={root["data-touch"]}
data-palette={root["data-palette"]}
className={cn(
"absolute overflow-hidden bg-background text-foreground",
root.className,
)}
data-testid="canvas-surface"
style={
{
...root.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, counted across the arranged area
// rather than the whole panel: the margin is not a cell.
"--dot-x": `${(area.width + GRID_GAP) / columnsOf(dashboard)}px`,
"--dot-y": `${rowPitchOf(dashboard, Boolean(rail))}px`,
} as React.CSSProperties
}
>
{/* Full bleed, and under everything: a background covers the
panel, margin included. */}
<CanvasGround dashboard={dashboard} />
{rail}
<div
className="absolute"
style={{
left: area.left,
top: area.top,
width: area.width,
height: area.height,
}}
>
{/* Over the ground rather than under it: the dots are what is
being arranged against, and a wallpaper must not hide them. */}
{dots ? (
<div className="dot-canvas pointer-events-none absolute inset-0" />
) : null}
{content}
</div>
</div>
</LookProvider>
) : null}
</div>
)
}
/**
* What the widgets sit on.
*
* The image a dashboard was given, or whatever ground its look brings — glass
* needs something behind it to refract, and material is happy with the surface
* colour alone.
*/
export function CanvasGround({ dashboard }: { dashboard: Dashboard }) {
const { Backdrop } = useUi()
return <Backdrop image={useDashboardBackground(dashboard)} />
}
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
})
/**
* A dashboard, drawn as the single grid the panel shows.
*
* A dashboard is one canvas — several dashboards on a panel is what the rail
* is for — so its widgets are one arrangement rather than a stack of headed
* grids. Pages and sections left the schema in 7ff29ca; a document written
* the old way is flattened on read.
*
* Drawn below a `LookProvider` mounted by whatever owns the canvas root — the
* surface, the editor, or the panel's stacked branch — rather than mounting a
* second one here, which could only ever restate what that one already says.
*/
export function DashboardView({
dashboard,
renderWidget,
stacked,
rail,
editing,
}: {
dashboard: Dashboard
renderWidget?: (widget: WidgetDef) => React.ReactNode
/** One column at the viewport's width, for a phone. */
stacked?: boolean
/** Whether the panel carries a rail, which takes a column of the canvas. */
rail?: boolean
/** Being arranged rather than used, so the lock is not mounted — the
* stacked editor draws through here, where the canvas draws its own grid. */
editing?: boolean
}) {
const all = widgetsOf(dashboard)
if (all.length === 0) {
return (
<p
className="text-sm text-muted-foreground"
data-testid="dashboard-empty"
>
Nothing on this dashboard yet. Edit it to add a widget.
</p>
)
}
// Stacked, the arrangement becomes a reading order, so it follows the rows
// the panel shows rather than the order widgets happened to be added in.
const widgets = stacked
? [...all].sort((a, b) => {
const left = placement(a)
const right = placement(b)
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
})
: all
const columns = columnsOf(dashboard)
const grid = (
<PaletteProvider dashboard={dashboard}>
<WidgetGrid
dashboard={dashboard}
widgets={widgets}
columns={columns}
stacked={stacked}
rail={rail}
renderWidget={renderWidget}
/>
</PaletteProvider>
)
return editing ? (
grid
) : (
<LockedProvider dashboard={dashboard}>{grid}</LockedProvider>
)
}
/**
* The arrangement itself.
*
* Its own component because it draws with the look's components, which are
* only knowable below `LookProvider`. Widgets arrive as a page rather than all
* at once — a stagger, at whatever stiffness the look moves with.
*/
function WidgetGrid({
dashboard,
widgets,
columns,
stacked,
rail,
renderWidget,
}: {
dashboard: Dashboard
widgets: WidgetDef[]
columns: number
stacked?: boolean
rail?: boolean
renderWidget?: (widget: WidgetDef) => React.ReactNode
}) {
const { Frame } = useUi()
const { look } = useLook()
return (
<motion.div
className={cn("widget-grid", stacked && "widget-stacked")}
data-placed={isPlaced(widgets) || undefined}
variants={gridStagger}
initial="hidden"
animate="visible"
style={
{
"--widget-cols": columns,
// The row height follows the canvas, so the CSS grid and the
// editor's grid library cannot drift apart.
"--row-height": `${rowHeightOf(dashboard, rail)}px`,
} as React.CSSProperties
}
>
{widgets.map((widget) => (
<motion.div
key={widget.id}
style={widgetStyle(widget, columns)}
className="widget-cell"
variants={LOOK[look].enter}
transition={LOOK[look].spring}
>
{renderWidget ? (
renderWidget(widget)
) : (
<Frame
title={showTitle(widget) ? widget.title : undefined}
issue={widgetIssue(widget)}
scroll={scrolls(widget)}
>
<WidgetBody widget={widget} dashboard={dashboard.name} />
</Frame>
)}
</motion.div>
))}
</motion.div>
)
}