Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ezWcLmBCtTVKFPxfdK2MK
255 lines
7.5 KiB
TypeScript
255 lines
7.5 KiB
TypeScript
import { Link } from "@tanstack/react-router"
|
|
import { LayoutDashboard } from "lucide-react"
|
|
|
|
import type { DashboardSummary, WidgetFootprint } from "@/client"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
/** Columns a dashboard is cut into when its document does not say. */
|
|
const DEFAULT_COLUMNS = 12
|
|
|
|
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
|
|
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
|
|
const INPUT = new Set([
|
|
"button",
|
|
"switch",
|
|
"slider",
|
|
"input",
|
|
"dropdown",
|
|
"color",
|
|
"counter",
|
|
])
|
|
|
|
const shade = (type: string) =>
|
|
INPUT.has(type)
|
|
? "bg-muted-foreground/30"
|
|
: GRAPHIC.has(type)
|
|
? "bg-primary/45"
|
|
: "bg-primary/20"
|
|
|
|
type Block = {
|
|
type: string
|
|
x: number
|
|
y: number
|
|
w: number
|
|
h: number
|
|
}
|
|
|
|
/** The dashboard's widget shapes as one grid. */
|
|
function blocksOf(footprint: WidgetFootprint[]): {
|
|
blocks: Block[]
|
|
rows: number
|
|
} {
|
|
const blocks: Block[] = []
|
|
let rows = 0
|
|
for (const shape of footprint) {
|
|
const { x = 0, y = 0, w = 3, h = 2 } = shape
|
|
blocks.push({
|
|
type: shape.type,
|
|
x: Math.max(0, x),
|
|
y: Math.max(0, y),
|
|
w: Math.max(1, w),
|
|
h: Math.max(1, h),
|
|
})
|
|
rows = Math.max(rows, Math.max(0, y) + Math.max(1, h))
|
|
}
|
|
return { blocks, rows: Math.max(1, rows) }
|
|
}
|
|
|
|
/**
|
|
* What a dashboard looks like from across the room: its widgets as blocks,
|
|
* shaded by what kind of thing each one is.
|
|
*
|
|
* A footprint rather than a live render. Nothing here subscribes to a message
|
|
* or reads a value — recognising "the one with the big chart on the left" is
|
|
* the whole job, and it has to cost nothing on a screen that is not the
|
|
* dashboard. The summary carries the shapes, so it costs no request either.
|
|
*/
|
|
function Footprint({ dashboard }: { dashboard: DashboardSummary }) {
|
|
if (dashboard.shape === "round") {
|
|
return <RoundFootprint faces={dashboard.footprint ?? []} />
|
|
}
|
|
const { blocks, rows } = blocksOf(dashboard.footprint ?? [])
|
|
const columns = dashboard.columns || DEFAULT_COLUMNS
|
|
// Before the editor could place things, every widget was written at 0,0;
|
|
// honouring that would pile the whole page onto one cell.
|
|
const placed = blocks.some((block) => block.x > 0 || block.y > 0)
|
|
const area = blocks.reduce((sum, block) => sum + block.w * block.h, 0)
|
|
|
|
if (blocks.length === 0) {
|
|
return (
|
|
<div className="flex aspect-video items-center justify-center rounded-sm bg-muted">
|
|
<LayoutDashboard className="size-5 text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
aria-hidden
|
|
className="grid aspect-video gap-0.5 overflow-hidden rounded-sm bg-muted p-1"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
gridTemplateRows: `repeat(${
|
|
placed ? rows : Math.max(1, Math.ceil(area / columns))
|
|
}, minmax(0, 1fr))`,
|
|
}}
|
|
>
|
|
{blocks.map((block, index) => {
|
|
const width = Math.min(columns, block.w)
|
|
return (
|
|
<span
|
|
key={index}
|
|
className={cn("rounded-[2px]", shade(block.type))}
|
|
style={
|
|
placed
|
|
? {
|
|
gridColumn: `${Math.min(columns - width, block.x) + 1} / span ${width}`,
|
|
gridRow: `${block.y + 1} / span ${block.h}`,
|
|
}
|
|
: { gridColumn: `span ${width}`, gridRow: `span ${block.h}` }
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A round dashboard shows one face at a time, so its grid places mean
|
|
* nothing: it is drawn as the circle, with a dot per face where a panel puts
|
|
* its page dots.
|
|
*/
|
|
function RoundFootprint({ faces }: { faces: WidgetFootprint[] }) {
|
|
return (
|
|
<div
|
|
aria-hidden
|
|
className="flex aspect-video items-center justify-center rounded-sm bg-muted p-1"
|
|
>
|
|
<div className="relative aspect-square h-full rounded-full border-2 border-primary/45">
|
|
<div className="absolute inset-x-[15%] bottom-[15%] flex flex-wrap justify-center gap-1">
|
|
{faces.map((face, index) => (
|
|
<span
|
|
key={index}
|
|
className={cn("size-2 rounded-full", shade(face.type))}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** One dashboard in the mosaic. */
|
|
function Tile({
|
|
dashboard,
|
|
className,
|
|
}: {
|
|
dashboard: DashboardSummary
|
|
/** What the layout needs of it — a width, in the scrolling strip. */
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<Link
|
|
to="/dashboards/$name"
|
|
params={{ name: dashboard.name }}
|
|
data-testid="home-dashboard-tile"
|
|
className={cn(
|
|
"grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50",
|
|
className,
|
|
)}
|
|
>
|
|
<Footprint dashboard={dashboard} />
|
|
<span className="flex items-center gap-2">
|
|
<span className="truncate text-sm font-medium">
|
|
{dashboard.title || dashboard.name}
|
|
</span>
|
|
{dashboard.has_draft ? (
|
|
<span className="size-1.5 shrink-0 rounded-full bg-primary">
|
|
<span className="sr-only">Unpublished changes</span>
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
/** When the working copy was last written, or the epoch for a summary without one. */
|
|
const modifiedAt = (summary: { updated_at?: string | null }) =>
|
|
summary.updated_at ? Date.parse(summary.updated_at) : 0
|
|
|
|
/**
|
|
* Which of two documents was worked on more recently.
|
|
*
|
|
* Both summaries carry the modified time of the working copy, so this is the
|
|
* order it says it is. The name only settles a tie, which is two documents
|
|
* written in the same instant — what a seed does.
|
|
*/
|
|
export function byRecency<
|
|
T extends { name: string; updated_at?: string | null },
|
|
>(a: T, b: T): number {
|
|
return modifiedAt(b) - modifiedAt(a) || a.name.localeCompare(b.name)
|
|
}
|
|
|
|
/**
|
|
* The dashboards, as the shapes they are.
|
|
*
|
|
* Two layouts, because it is read two ways: a column of pairs where it sits
|
|
* beside something else, and one wide strip where it has the page to itself.
|
|
* A tile is `content-start` around an `aspect-video` footprint and so has no
|
|
* width of its own — the strip has to give it one.
|
|
*/
|
|
export function DashboardMosaic({
|
|
dashboards,
|
|
isPending,
|
|
row = false,
|
|
}: {
|
|
dashboards: DashboardSummary[]
|
|
isPending: boolean
|
|
/** One scrolling strip instead of a two-column grid. */
|
|
row?: boolean
|
|
}) {
|
|
const container = row
|
|
? "flex snap-x snap-mandatory gap-3 overflow-x-auto p-3"
|
|
: "grid gap-3 p-3 sm:grid-cols-2"
|
|
const tile = row ? "w-56 shrink-0 snap-start" : ""
|
|
|
|
if (isPending) {
|
|
return (
|
|
<div className={container}>
|
|
{Array.from({ length: 2 }).map((_, index) => (
|
|
<Skeleton
|
|
key={index}
|
|
className={cn("aspect-[4/3] rounded-lg", tile)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (dashboards.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
<LayoutDashboard className="size-5" />
|
|
</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
Dashboards you build show up here, as the shapes they are.
|
|
</p>
|
|
<Link to="/dashboards" className="text-sm font-medium underline">
|
|
Go to dashboards
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={container}>
|
|
{dashboards.map((dashboard) => (
|
|
<Tile key={dashboard.name} dashboard={dashboard} className={tile} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|