Give a dashboard the panel's own size, and put the dots on its grid
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
This commit is contained in:
@@ -180,6 +180,12 @@ class DashboardDef(BaseModel):
|
||||
#: How many columns the grid is cut into, so a dashboard can be matched to
|
||||
#: the panel it will hang on.
|
||||
columns: int = Field(default=12, ge=1, le=48)
|
||||
#: The panel this dashboard is drawn for, in CSS pixels. Both the editor
|
||||
#: and the wall panel scale that surface to fit whatever room they have, so
|
||||
#: an arrangement does not depend on the window it was made in. Zero means
|
||||
#: "unset" and the client falls back to its default.
|
||||
canvas_width: int = Field(default=1920, ge=0, le=7680)
|
||||
canvas_height: int = Field(default=1080, ge=0, le=4320)
|
||||
pages: list[PageDef] = Field(default_factory=list)
|
||||
#: Bumped on every save; a save based on an older one is refused.
|
||||
version: int = 1
|
||||
|
||||
@@ -218,6 +218,20 @@ export const DashboardDef_InputSchema = {
|
||||
title: 'Columns',
|
||||
default: 12
|
||||
},
|
||||
canvas_width: {
|
||||
type: 'integer',
|
||||
maximum: 7680,
|
||||
minimum: 0,
|
||||
title: 'Canvas Width',
|
||||
default: 1920
|
||||
},
|
||||
canvas_height: {
|
||||
type: 'integer',
|
||||
maximum: 4320,
|
||||
minimum: 0,
|
||||
title: 'Canvas Height',
|
||||
default: 1080
|
||||
},
|
||||
pages: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/PageDef-Input'
|
||||
@@ -255,6 +269,20 @@ export const DashboardDef_OutputSchema = {
|
||||
title: 'Columns',
|
||||
default: 12
|
||||
},
|
||||
canvas_width: {
|
||||
type: 'integer',
|
||||
maximum: 7680,
|
||||
minimum: 0,
|
||||
title: 'Canvas Width',
|
||||
default: 1920
|
||||
},
|
||||
canvas_height: {
|
||||
type: 'integer',
|
||||
maximum: 4320,
|
||||
minimum: 0,
|
||||
title: 'Canvas Height',
|
||||
default: 1080
|
||||
},
|
||||
pages: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/PageDef-Output'
|
||||
|
||||
@@ -83,6 +83,8 @@ export type DashboardDef_Input = {
|
||||
name: string;
|
||||
title?: string;
|
||||
columns?: number;
|
||||
canvas_width?: number;
|
||||
canvas_height?: number;
|
||||
pages?: Array<PageDef_Input>;
|
||||
version?: number;
|
||||
};
|
||||
@@ -94,6 +96,8 @@ export type DashboardDef_Output = {
|
||||
name: string;
|
||||
title?: string;
|
||||
columns?: number;
|
||||
canvas_width?: number;
|
||||
canvas_height?: number;
|
||||
pages?: Array<PageDef_Output>;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
@@ -9,8 +9,13 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout"
|
||||
import { type CSSProperties, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
GridLayout,
|
||||
type Layout,
|
||||
type Position,
|
||||
setTransform,
|
||||
} from "react-grid-layout"
|
||||
import "react-grid-layout/css/styles.css"
|
||||
|
||||
import {
|
||||
@@ -39,6 +44,8 @@ import { slideUp, transitions } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { handleError } from "@/utils"
|
||||
import {
|
||||
CanvasSurface,
|
||||
canvasOf,
|
||||
columnsOf,
|
||||
type Dashboard,
|
||||
DashboardView,
|
||||
@@ -47,6 +54,7 @@ import {
|
||||
pagesOf,
|
||||
placement,
|
||||
ROW_HEIGHT,
|
||||
rowsOf,
|
||||
sectionsOf,
|
||||
widgetsOf,
|
||||
} from "./DashboardView"
|
||||
@@ -127,6 +135,20 @@ function layoutOf(widgets: WidgetDef[], columns: number): Layout {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* How the grid positions and drags items on a CSS-scaled surface.
|
||||
*
|
||||
* The library's own `createScaledStrategy` divides the drag delta by the scale
|
||||
* but drops the container offset while doing it, so a widget jumps on pick-up.
|
||||
* Leaving `calcDragPosition` unset keeps its parent-relative maths, which
|
||||
* already divides by `scale` — the only thing that needed saying.
|
||||
*/
|
||||
const scaledStrategy = (scale: number) => ({
|
||||
type: "transform" as const,
|
||||
scale,
|
||||
calcStyle: setTransform as (pos: Position) => CSSProperties,
|
||||
})
|
||||
|
||||
const same = (a: Layout, b: Layout) =>
|
||||
a.length === b.length &&
|
||||
a.every((item, index) => {
|
||||
@@ -204,7 +226,7 @@ export function DashboardEditor({
|
||||
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||
const section = page ? sectionsOf(page)[0] : undefined
|
||||
const widgets = section ? widgetsOf(section) : []
|
||||
const { width, containerRef, mounted } = useContainerWidth()
|
||||
const canvas = canvasOf(draft)
|
||||
|
||||
const updateWidgets = (next: WidgetDef[]) => {
|
||||
if (!page || !section) return
|
||||
@@ -293,63 +315,67 @@ export function DashboardEditor({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This dashboard has no pages yet.
|
||||
</p>
|
||||
) : !edit ? (
|
||||
<DashboardView dashboard={draft} pageId={page.id} />
|
||||
) : widgets.length === 0 ? (
|
||||
) : edit && widgets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
|
||||
Nothing on this page yet. Add a widget from the bar below.
|
||||
</p>
|
||||
) : (
|
||||
<div ref={containerRef}>
|
||||
{/* Measured first: laying out against a guessed width would place every
|
||||
widget once and then move it. */}
|
||||
{mounted ? (
|
||||
<GridLayout
|
||||
width={width}
|
||||
layout={layout}
|
||||
onLayoutChange={applyLayout}
|
||||
gridConfig={{
|
||||
cols: columns,
|
||||
rowHeight: ROW_HEIGHT,
|
||||
margin: [GRID_GAP, GRID_GAP],
|
||||
containerPadding: [0, 0],
|
||||
}}
|
||||
// Only the header moves a widget, so a slider under the cursor still
|
||||
// slides and a switch still flips while the dashboard is being edited.
|
||||
dragConfig={{ handle: ".widget-grip" }}
|
||||
resizeConfig={{ handles: ["e", "s", "se"] }}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<WidgetFrame
|
||||
title={widget.title}
|
||||
issue={widgetIssue(widget)}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
widget.id === selected && "ring-2 ring-primary",
|
||||
)}
|
||||
grip
|
||||
onClick={(event) => {
|
||||
if (!(event.target as Element).closest(INTERACTIVE)) {
|
||||
setSettingsOpen(false)
|
||||
setSelected(widget.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||
</WidgetFrame>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
) : null}
|
||||
</div>
|
||||
<CanvasSurface dashboard={draft} dots={edit}>
|
||||
{(scale) =>
|
||||
!edit ? (
|
||||
<DashboardView dashboard={draft} pageId={page.id} />
|
||||
) : (
|
||||
<GridLayout
|
||||
width={canvas.width}
|
||||
layout={layout}
|
||||
onLayoutChange={applyLayout}
|
||||
positionStrategy={scaledStrategy(scale)}
|
||||
gridConfig={{
|
||||
cols: columns,
|
||||
rowHeight: ROW_HEIGHT,
|
||||
margin: [GRID_GAP, GRID_GAP],
|
||||
containerPadding: [0, 0],
|
||||
// The canvas is the constraint: nothing may be dragged off the
|
||||
// panel it is being drawn for.
|
||||
maxRows: rowsOf(draft),
|
||||
}}
|
||||
// Only the header moves a widget, so a slider under the cursor still
|
||||
// slides and a switch still flips while the dashboard is being edited.
|
||||
dragConfig={{ handle: ".widget-grip" }}
|
||||
resizeConfig={{ handles: ["e", "s", "se"] }}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<WidgetFrame
|
||||
title={widget.title}
|
||||
issue={widgetIssue(widget)}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
widget.id === selected && "ring-2 ring-primary",
|
||||
)}
|
||||
grip
|
||||
onClick={(event) => {
|
||||
if (!(event.target as Element).closest(INTERACTIVE)) {
|
||||
setSettingsOpen(false)
|
||||
setSelected(widget.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||
</WidgetFrame>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
)
|
||||
}
|
||||
</CanvasSurface>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"dot-canvas absolute inset-0 overflow-y-auto px-4 pb-24 pt-20 transition-[padding] duration-200",
|
||||
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
|
||||
panelOpen && "md:pr-[27rem]",
|
||||
)}
|
||||
data-testid="dashboard-canvas"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import type {
|
||||
DashboardDef_Output,
|
||||
PageDef_Output,
|
||||
@@ -17,12 +19,27 @@ 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.
|
||||
@@ -34,6 +51,82 @@ 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 ?? {}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* so nothing here touches the byte-identical token blocks.
|
||||
*/
|
||||
|
||||
/* The same dot grid the flow viewport paints, as plain CSS: a dashboard has no
|
||||
zoom, so it needs none of React Flow's rescaling. */
|
||||
/* The placement grid, as plain CSS. One dot per cell corner: the surface hands
|
||||
in the pitch its widgets actually snap to (`--dot-x` / `--dot-y`), and the
|
||||
negative offset puts a dot centre on the grid's own origin. */
|
||||
.dot-canvas {
|
||||
background-color: var(--background);
|
||||
background-image: radial-gradient(
|
||||
@@ -15,42 +16,34 @@
|
||||
color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px,
|
||||
transparent 0
|
||||
);
|
||||
background-size: 24px 24px;
|
||||
background-size: var(--dot-x, 24px) var(--dot-y, 24px);
|
||||
background-position: -1px -1px;
|
||||
}
|
||||
|
||||
/*
|
||||
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
|
||||
* panel can be matched to its own width. Below the large breakpoint the stored
|
||||
* placement is meaningless — three columns cannot hold a twelve-column
|
||||
* arrangement — so widgets stack full width and keep only their height.
|
||||
* panel can be matched to its own width. There is no responsive fallback: the
|
||||
* grid lives on a canvas of the panel's own pixel size, which is scaled to the
|
||||
* viewport rather than reflowed into it.
|
||||
*/
|
||||
.widget-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-auto-rows: 5rem;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.widget-cell {
|
||||
grid-column: span var(--w);
|
||||
grid-row: span var(--h);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.widget-grid {
|
||||
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.widget-cell {
|
||||
grid-column: span var(--w);
|
||||
}
|
||||
|
||||
/* Only once something has actually been placed; an untouched dashboard has
|
||||
every widget at 0,0 and would pile up on one cell. */
|
||||
.widget-grid[data-placed] .widget-cell {
|
||||
grid-column: var(--x) / span var(--w);
|
||||
grid-row: var(--y) / span var(--h);
|
||||
}
|
||||
/* Only once something has actually been placed; an untouched dashboard has
|
||||
every widget at 0,0 and would pile up on one cell. */
|
||||
.widget-grid[data-placed] .widget-cell {
|
||||
grid-column: var(--x) / span var(--w);
|
||||
grid-row: var(--y) / span var(--h);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -27,7 +27,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { MAX_SERIES } from "./ChartWidget"
|
||||
import { COLUMN_CHOICES, columnsOf, type Dashboard } from "./DashboardView"
|
||||
import {
|
||||
CANVAS_PRESETS,
|
||||
COLUMN_CHOICES,
|
||||
canvasOf,
|
||||
columnsOf,
|
||||
type Dashboard,
|
||||
} from "./DashboardView"
|
||||
import { messageCatalogQueryOptions } from "./queries"
|
||||
import {
|
||||
acceptsDtype,
|
||||
@@ -314,12 +320,16 @@ export function WidgetPanel({
|
||||
)
|
||||
}
|
||||
|
||||
/** Presets are matched on their size, so a custom one simply matches none. */
|
||||
const sizeKey = (size: { width: number; height: number }) =>
|
||||
`${size.width}x${size.height}`
|
||||
|
||||
/**
|
||||
* The dashboard's own settings, in the panel its widgets use.
|
||||
*
|
||||
* The grid size is the one that matters: a wall panel is a fixed width, and
|
||||
* twelve columns on a seven-inch screen is a different dashboard than twelve
|
||||
* on a television.
|
||||
* The canvas is the one that matters: a wall panel is a fixed size, and twelve
|
||||
* columns on a seven-inch screen is a different dashboard than twelve on a
|
||||
* television.
|
||||
*/
|
||||
export function DashboardPanel({
|
||||
open,
|
||||
@@ -337,6 +347,7 @@ export function DashboardPanel({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const canvas = canvasOf(dashboard)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -390,6 +401,65 @@ export function DashboardPanel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Canvas</span>
|
||||
<Select
|
||||
value={
|
||||
CANVAS_PRESETS.some(
|
||||
(preset) => sizeKey(preset) === sizeKey(canvas),
|
||||
)
|
||||
? sizeKey(canvas)
|
||||
: ""
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
const preset = CANVAS_PRESETS.find(
|
||||
(candidate) => sizeKey(candidate) === value,
|
||||
)
|
||||
if (preset)
|
||||
onChange({
|
||||
canvas_width: preset.width,
|
||||
canvas_height: preset.height,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger data-testid="dashboard-canvas-size">
|
||||
<SelectValue placeholder="Custom" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CANVAS_PRESETS.map((preset) => (
|
||||
<SelectItem key={sizeKey(preset)} value={sizeKey(preset)}>
|
||||
{preset.label} — {preset.width}×{preset.height}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Canvas width"
|
||||
data-testid="canvas-width"
|
||||
value={canvas.width}
|
||||
onChange={(event) =>
|
||||
onChange({ canvas_width: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Canvas height"
|
||||
data-testid="canvas-height"
|
||||
value={canvas.height}
|
||||
onChange={(event) =>
|
||||
onChange({ canvas_height: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The panel this is drawn for, in pixels. Editing and viewing both
|
||||
scale that surface to fit, so the arrangement is the same
|
||||
everywhere.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Contents</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import { DashboardView } from "@/components/Dashboard/DashboardView"
|
||||
import {
|
||||
CanvasSurface,
|
||||
DashboardView,
|
||||
} from "@/components/Dashboard/DashboardView"
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
@@ -30,8 +33,14 @@ function PanelView() {
|
||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||
|
||||
return (
|
||||
<main className="dot-canvas min-h-svh w-full overflow-y-auto p-4">
|
||||
{dashboard ? <DashboardView dashboard={dashboard as Dashboard} /> : null}
|
||||
<main className="h-svh w-full overflow-hidden p-4">
|
||||
{dashboard ? (
|
||||
// The panel's own surface, scaled to whatever screen it landed on. No
|
||||
// dots: nothing is being arranged here.
|
||||
<CanvasSurface dashboard={dashboard as Dashboard}>
|
||||
{() => <DashboardView dashboard={dashboard as Dashboard} />}
|
||||
</CanvasSurface>
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user