Home mosaic, multi-select delete, offline banner and loading states
- Home puts the dashboards beside the flows: two equal-height columns, capped and scrollable, most recently worked on first. Each tile is a schematic footprint built from the stored widget placements. - Flows and dashboards can be picked by long press or ctrl-click; the create button becomes a trash and one dialog covers the batch. - The offline banner is drawn on the body so it centres on the viewport, and the live socket now releases the offline latch a stray 503 set. - A boot spinner before React's first commit, a router pending screen for code-split pages, and skeletons where an empty list used to flash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { LayoutDashboard } from "lucide-react"
|
||||
|
||||
import type {
|
||||
DashboardDef_Output,
|
||||
DashboardSummary,
|
||||
Placement,
|
||||
WidgetDef,
|
||||
} from "@/client"
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
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
|
||||
|
||||
/**
|
||||
* How many tiles are worth a request of their own.
|
||||
*
|
||||
* ponytail: the list endpoint carries no placements, so a footprint means
|
||||
* reading that dashboard's document — cheap for the handful an installation
|
||||
* has, and shared with the editor's own cache. The ceiling is an installation
|
||||
* with dozens: the tiles past this show their name and nothing else, and the
|
||||
* fix would be a stored footprint on `DashboardSummary`.
|
||||
*/
|
||||
const PREVIEWS = 8
|
||||
|
||||
/** 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"])
|
||||
|
||||
const shade = (type: string) =>
|
||||
INPUT.has(type)
|
||||
? "bg-muted-foreground/30"
|
||||
: GRAPHIC.has(type)
|
||||
? "bg-primary/45"
|
||||
: "bg-primary/20"
|
||||
|
||||
/**
|
||||
* Where a widget sits, at the width a panel is arranged for.
|
||||
*
|
||||
* The same three-line fallback as `Dashboard/DashboardView`, written out again
|
||||
* rather than imported: that module pulls the whole dashboard chunk, and this
|
||||
* draws a schematic on a screen that shows no dashboards.
|
||||
*/
|
||||
const placement = (widget: WidgetDef): Placement => {
|
||||
const layout = (widget.layout ?? {}) as Record<string, Placement>
|
||||
return layout.lg ?? layout.md ?? layout.sm ?? {}
|
||||
}
|
||||
|
||||
type Block = {
|
||||
id: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The first page's widgets as one grid.
|
||||
*
|
||||
* Sections are separate grids on the real thing, each starting at its own row
|
||||
* zero, so each is pushed down past the one before it to keep them apart here.
|
||||
*/
|
||||
function blocksOf(dashboard: DashboardDef_Output): {
|
||||
blocks: Block[]
|
||||
rows: number
|
||||
} {
|
||||
const blocks: Block[] = []
|
||||
let offset = 0
|
||||
for (const section of (dashboard.pages ?? [])[0]?.sections ?? []) {
|
||||
let bottom = 0
|
||||
for (const widget of section.widgets ?? []) {
|
||||
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
|
||||
blocks.push({
|
||||
id: widget.id,
|
||||
type: widget.type,
|
||||
x: Math.max(0, x),
|
||||
y: Math.max(0, y) + offset,
|
||||
w: Math.max(1, w),
|
||||
h: Math.max(1, h),
|
||||
})
|
||||
bottom = Math.max(bottom, Math.max(0, y) + Math.max(1, h))
|
||||
}
|
||||
offset += bottom
|
||||
}
|
||||
return { blocks, rows: Math.max(1, offset) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
|
||||
const { blocks, rows } = blocksOf(dashboard)
|
||||
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) => {
|
||||
const width = Math.min(columns, block.w)
|
||||
return (
|
||||
<span
|
||||
key={block.id}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One dashboard in the mosaic.
|
||||
*
|
||||
* Its own query, so the tiles fill in as their documents arrive instead of the
|
||||
* whole panel waiting for the slowest of them.
|
||||
*/
|
||||
function Tile({
|
||||
dashboard,
|
||||
preview,
|
||||
}: {
|
||||
dashboard: DashboardSummary
|
||||
/** Read the document for a footprint, or settle for the name alone. */
|
||||
preview: boolean
|
||||
}) {
|
||||
// The working copy, which is what the list itself is a summary of, so the
|
||||
// preview shows what an editor would open rather than the last publish.
|
||||
const { data, isPending } = useQuery({
|
||||
...dashboardQueryOptions(dashboard.name, true),
|
||||
enabled: preview,
|
||||
})
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/dashboards/$name"
|
||||
params={{ name: dashboard.name }}
|
||||
data-testid="home-dashboard-tile"
|
||||
className="grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
{preview && isPending ? (
|
||||
<Skeleton className="aspect-video rounded-sm" />
|
||||
) : data ? (
|
||||
<Footprint dashboard={data} />
|
||||
) : (
|
||||
<div className="flex aspect-video items-center justify-center rounded-sm bg-muted">
|
||||
<LayoutDashboard className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of two documents was worked on more recently.
|
||||
*
|
||||
* ponytail: neither `FlowSummary` nor `DashboardSummary` carries a modified
|
||||
* time, so this reads the two things that come close — an unpublished edit is
|
||||
* the one someone has open, and a higher version counter has been saved more
|
||||
* often. An `updated_at` on both summaries is what would make it exact.
|
||||
*/
|
||||
export function byRecency<
|
||||
T extends { name: string; has_draft?: boolean; version?: number },
|
||||
>(a: T, b: T): number {
|
||||
return (
|
||||
Number(b.has_draft ?? false) - Number(a.has_draft ?? false) ||
|
||||
(b.version ?? 0) - (a.version ?? 0) ||
|
||||
a.name.localeCompare(b.name)
|
||||
)
|
||||
}
|
||||
|
||||
/** The dashboards, as the shapes they are, beside the flows on the home view. */
|
||||
export function DashboardMosaic({
|
||||
dashboards,
|
||||
isPending,
|
||||
}: {
|
||||
dashboards: DashboardSummary[]
|
||||
isPending: boolean
|
||||
}) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="grid gap-3 p-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton key={index} className="aspect-[4/3] rounded-lg" />
|
||||
))}
|
||||
</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="grid gap-3 p-3 sm:grid-cols-2">
|
||||
{dashboards.map((dashboard, index) => (
|
||||
<Tile
|
||||
key={dashboard.name}
|
||||
dashboard={dashboard}
|
||||
preview={index < PREVIEWS}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user