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
|
#: How many columns the grid is cut into, so a dashboard can be matched to
|
||||||
#: the panel it will hang on.
|
#: the panel it will hang on.
|
||||||
columns: int = Field(default=12, ge=1, le=48)
|
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)
|
pages: list[PageDef] = Field(default_factory=list)
|
||||||
#: Bumped on every save; a save based on an older one is refused.
|
#: Bumped on every save; a save based on an older one is refused.
|
||||||
version: int = 1
|
version: int = 1
|
||||||
|
|||||||
@@ -218,6 +218,20 @@ export const DashboardDef_InputSchema = {
|
|||||||
title: 'Columns',
|
title: 'Columns',
|
||||||
default: 12
|
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: {
|
pages: {
|
||||||
items: {
|
items: {
|
||||||
'$ref': '#/components/schemas/PageDef-Input'
|
'$ref': '#/components/schemas/PageDef-Input'
|
||||||
@@ -255,6 +269,20 @@ export const DashboardDef_OutputSchema = {
|
|||||||
title: 'Columns',
|
title: 'Columns',
|
||||||
default: 12
|
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: {
|
pages: {
|
||||||
items: {
|
items: {
|
||||||
'$ref': '#/components/schemas/PageDef-Output'
|
'$ref': '#/components/schemas/PageDef-Output'
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export type DashboardDef_Input = {
|
|||||||
name: string;
|
name: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
columns?: number;
|
columns?: number;
|
||||||
|
canvas_width?: number;
|
||||||
|
canvas_height?: number;
|
||||||
pages?: Array<PageDef_Input>;
|
pages?: Array<PageDef_Input>;
|
||||||
version?: number;
|
version?: number;
|
||||||
};
|
};
|
||||||
@@ -94,6 +96,8 @@ export type DashboardDef_Output = {
|
|||||||
name: string;
|
name: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
columns?: number;
|
columns?: number;
|
||||||
|
canvas_width?: number;
|
||||||
|
canvas_height?: number;
|
||||||
pages?: Array<PageDef_Output>;
|
pages?: Array<PageDef_Output>;
|
||||||
version?: number;
|
version?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
import { motion } from "motion/react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { type CSSProperties, useEffect, useRef, useState } from "react"
|
||||||
import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout"
|
import {
|
||||||
|
GridLayout,
|
||||||
|
type Layout,
|
||||||
|
type Position,
|
||||||
|
setTransform,
|
||||||
|
} from "react-grid-layout"
|
||||||
import "react-grid-layout/css/styles.css"
|
import "react-grid-layout/css/styles.css"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +44,8 @@ import { slideUp, transitions } from "@/lib/motion"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
import {
|
import {
|
||||||
|
CanvasSurface,
|
||||||
|
canvasOf,
|
||||||
columnsOf,
|
columnsOf,
|
||||||
type Dashboard,
|
type Dashboard,
|
||||||
DashboardView,
|
DashboardView,
|
||||||
@@ -47,6 +54,7 @@ import {
|
|||||||
pagesOf,
|
pagesOf,
|
||||||
placement,
|
placement,
|
||||||
ROW_HEIGHT,
|
ROW_HEIGHT,
|
||||||
|
rowsOf,
|
||||||
sectionsOf,
|
sectionsOf,
|
||||||
widgetsOf,
|
widgetsOf,
|
||||||
} from "./DashboardView"
|
} 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) =>
|
const same = (a: Layout, b: Layout) =>
|
||||||
a.length === b.length &&
|
a.length === b.length &&
|
||||||
a.every((item, index) => {
|
a.every((item, index) => {
|
||||||
@@ -204,7 +226,7 @@ export function DashboardEditor({
|
|||||||
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||||
const section = page ? sectionsOf(page)[0] : undefined
|
const section = page ? sectionsOf(page)[0] : undefined
|
||||||
const widgets = section ? widgetsOf(section) : []
|
const widgets = section ? widgetsOf(section) : []
|
||||||
const { width, containerRef, mounted } = useContainerWidth()
|
const canvas = canvasOf(draft)
|
||||||
|
|
||||||
const updateWidgets = (next: WidgetDef[]) => {
|
const updateWidgets = (next: WidgetDef[]) => {
|
||||||
if (!page || !section) return
|
if (!page || !section) return
|
||||||
@@ -293,26 +315,29 @@ export function DashboardEditor({
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This dashboard has no pages yet.
|
This dashboard has no pages yet.
|
||||||
</p>
|
</p>
|
||||||
) : !edit ? (
|
) : edit && widgets.length === 0 ? (
|
||||||
<DashboardView dashboard={draft} pageId={page.id} />
|
|
||||||
) : widgets.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
|
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
|
||||||
Nothing on this page yet. Add a widget from the bar below.
|
Nothing on this page yet. Add a widget from the bar below.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div ref={containerRef}>
|
<CanvasSurface dashboard={draft} dots={edit}>
|
||||||
{/* Measured first: laying out against a guessed width would place every
|
{(scale) =>
|
||||||
widget once and then move it. */}
|
!edit ? (
|
||||||
{mounted ? (
|
<DashboardView dashboard={draft} pageId={page.id} />
|
||||||
|
) : (
|
||||||
<GridLayout
|
<GridLayout
|
||||||
width={width}
|
width={canvas.width}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
onLayoutChange={applyLayout}
|
onLayoutChange={applyLayout}
|
||||||
|
positionStrategy={scaledStrategy(scale)}
|
||||||
gridConfig={{
|
gridConfig={{
|
||||||
cols: columns,
|
cols: columns,
|
||||||
rowHeight: ROW_HEIGHT,
|
rowHeight: ROW_HEIGHT,
|
||||||
margin: [GRID_GAP, GRID_GAP],
|
margin: [GRID_GAP, GRID_GAP],
|
||||||
containerPadding: [0, 0],
|
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
|
// 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.
|
// slides and a switch still flips while the dashboard is being edited.
|
||||||
@@ -341,15 +366,16 @@ export function DashboardEditor({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</GridLayout>
|
</GridLayout>
|
||||||
) : null}
|
)
|
||||||
</div>
|
}
|
||||||
|
</CanvasSurface>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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]",
|
panelOpen && "md:pr-[27rem]",
|
||||||
)}
|
)}
|
||||||
data-testid="dashboard-canvas"
|
data-testid="dashboard-canvas"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DashboardDef_Output,
|
DashboardDef_Output,
|
||||||
PageDef_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. */
|
/** 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]
|
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. */
|
/** One grid row, in pixels — the unit widget heights are multiples of. */
|
||||||
export const ROW_HEIGHT = 80
|
export const ROW_HEIGHT = 80
|
||||||
|
|
||||||
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
|
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
|
||||||
export const GRID_GAP = 12
|
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
|
* The generated client marks every list optional, because the server fills
|
||||||
* them in. These three keep that from spreading through the components.
|
* 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) =>
|
export const columnsOf = (dashboard: Dashboard) =>
|
||||||
dashboard.columns || DEFAULT_COLUMNS
|
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 {
|
export function placement(widget: WidgetDef): Placement {
|
||||||
const layout = (widget.layout ?? {}) as Record<string, Placement>
|
const layout = (widget.layout ?? {}) as Record<string, Placement>
|
||||||
return layout.lg ?? layout.md ?? layout.sm ?? {}
|
return layout.lg ?? layout.md ?? layout.sm ?? {}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
* so nothing here touches the byte-identical token blocks.
|
* so nothing here touches the byte-identical token blocks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* The same dot grid the flow viewport paints, as plain CSS: a dashboard has no
|
/* The placement grid, as plain CSS. One dot per cell corner: the surface hands
|
||||||
zoom, so it needs none of React Flow's rescaling. */
|
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 {
|
.dot-canvas {
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
@@ -15,42 +16,34 @@
|
|||||||
color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px,
|
color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px,
|
||||||
transparent 0
|
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
|
* 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
|
* panel can be matched to its own width. There is no responsive fallback: the
|
||||||
* placement is meaningless — three columns cannot hold a twelve-column
|
* grid lives on a canvas of the panel's own pixel size, which is scaled to the
|
||||||
* arrangement — so widgets stack full width and keep only their height.
|
* viewport rather than reflowed into it.
|
||||||
*/
|
*/
|
||||||
.widget-grid {
|
.widget-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
grid-auto-rows: 5rem;
|
grid-auto-rows: 5rem;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-cell {
|
.widget-cell {
|
||||||
|
grid-column: span var(--w);
|
||||||
grid-row: span var(--h);
|
grid-row: span var(--h);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 64rem) {
|
/* Only once something has actually been placed; an untouched dashboard has
|
||||||
.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. */
|
every widget at 0,0 and would pile up on one cell. */
|
||||||
.widget-grid[data-placed] .widget-cell {
|
.widget-grid[data-placed] .widget-cell {
|
||||||
grid-column: var(--x) / span var(--w);
|
grid-column: var(--x) / span var(--w);
|
||||||
grid-row: var(--y) / span var(--h);
|
grid-row: var(--y) / span var(--h);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { MAX_SERIES } from "./ChartWidget"
|
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 { messageCatalogQueryOptions } from "./queries"
|
||||||
import {
|
import {
|
||||||
acceptsDtype,
|
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 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
|
* The canvas is the one that matters: a wall panel is a fixed size, and twelve
|
||||||
* twelve columns on a seven-inch screen is a different dashboard than twelve
|
* columns on a seven-inch screen is a different dashboard than twelve on a
|
||||||
* on a television.
|
* television.
|
||||||
*/
|
*/
|
||||||
export function DashboardPanel({
|
export function DashboardPanel({
|
||||||
open,
|
open,
|
||||||
@@ -337,6 +347,7 @@ export function DashboardPanel({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
const canvas = canvasOf(dashboard)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -390,6 +401,65 @@ export function DashboardPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
<span className={PANEL_SECTION}>Contents</span>
|
<span className={PANEL_SECTION}>Contents</span>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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 { createFileRoute, redirect } from "@tanstack/react-router"
|
||||||
|
|
||||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
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 { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||||
import { isLoggedIn } from "@/hooks/useAuth"
|
import { isLoggedIn } from "@/hooks/useAuth"
|
||||||
@@ -30,8 +33,14 @@ function PanelView() {
|
|||||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="dot-canvas min-h-svh w-full overflow-y-auto p-4">
|
<main className="h-svh w-full overflow-hidden p-4">
|
||||||
{dashboard ? <DashboardView dashboard={dashboard as Dashboard} /> : null}
|
{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>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user