Build dashboards you can actually look at and press
Widgets bind to a message name and read it live off the socket the editor already had — lifted out of the flow editor so a dashboard route gets the same values, which also gives the home page live data for free. The input widgets close the loop the other way: a slider publishes into the graph and whatever consumes that message runs. Verified end to end in the running app — moving a slider set a flow input, and the stat bound to what the flow computed from it followed. View mode is plain CSS grid. A wall panel that only displays should not download the code that lets someone drag things around, and it now does not. Editing is a widget picker, a per-widget width control and a settings card fed by the message catalog. No new dependencies: the slider is a range input, the gauge is an arc, and the markdown is a five-line subset. Charts are the one widget still missing — they need a charting library and the chart tokens the design guidelines reserved — so they are stored and validated but not offered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
DashboardDef_Output,
|
||||
PageDef_Output,
|
||||
SectionDef_Output,
|
||||
WidgetDef,
|
||||
} from "@/client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { WidgetBody, WidgetFrame } from "./widgets"
|
||||
|
||||
/**
|
||||
* Columns per breakpoint. A wall panel gets the full twelve, a phone gets
|
||||
* three, which is what makes a stat tile still readable at arm's length.
|
||||
*/
|
||||
const GRID = "grid-cols-3 md:grid-cols-6 lg:grid-cols-12"
|
||||
|
||||
/** One grid row, in pixels. Widget heights are multiples of this. */
|
||||
const ROW = "5rem"
|
||||
|
||||
/**
|
||||
* 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 ?? []
|
||||
|
||||
function placement(widget: WidgetDef) {
|
||||
const layout = (widget.layout ?? {}) as Record<
|
||||
string,
|
||||
{ x?: number; y?: number; w?: number; h?: number }
|
||||
>
|
||||
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): React.CSSProperties {
|
||||
const { w = 3, h = 2 } = placement(widget)
|
||||
return {
|
||||
gridColumn: `span ${Math.min(12, Math.max(1, w))}`,
|
||||
gridRow: `span ${Math.max(1, h)}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function SectionGrid({
|
||||
section,
|
||||
renderWidget,
|
||||
className,
|
||||
}: {
|
||||
section: SectionDef_Output
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
{section.title ? (
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
{section.title}
|
||||
</h2>
|
||||
) : null}
|
||||
<div
|
||||
className={cn("grid gap-3", GRID, className)}
|
||||
style={{ gridAutoRows: ROW }}
|
||||
>
|
||||
{widgetsOf(section).map((widget) => (
|
||||
<div key={widget.id} style={widgetStyle(widget)} className="min-w-0">
|
||||
{renderWidget ? (
|
||||
renderWidget(widget)
|
||||
) : (
|
||||
<WidgetFrame title={widget.title}>
|
||||
<WidgetBody widget={widget} />
|
||||
</WidgetFrame>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardView({
|
||||
dashboard,
|
||||
pageId,
|
||||
renderWidget,
|
||||
}: {
|
||||
dashboard: DashboardDef_Output
|
||||
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}
|
||||
renderWidget={renderWidget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user