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:
2026-08-22 12:02:14 +02:00
co-authored by Claude Opus 5
parent 6d84316ce5
commit f17d51c12f
13 changed files with 837 additions and 89 deletions
@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { LayoutDashboard, MonitorSmartphone } from "lucide-react"
import { useState } from "react"
import { DashboardsService } from "@/client"
import { OverviewCard, useSelection } from "@/components/Common/OverviewCard"
import {
ConfirmDelete,
OverviewToolbar,
useDeleteSelected,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import { PanelsDialog } from "@/components/Dashboard/PanelsDialog"
@@ -23,6 +26,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
@@ -36,7 +40,7 @@ export const Route = createFileRoute("/_layout/dashboards/")({
})
function Dashboards() {
const { data } = useQuery(dashboardsQueryOptions())
const { data, isPending } = useQuery(dashboardsQueryOptions())
const queryClient = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
@@ -44,6 +48,7 @@ function Dashboards() {
const [search, setSearch] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [panelsOpen, setPanelsOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const create = useMutation({
mutationFn: (dashboard: string) =>
@@ -87,6 +92,13 @@ function Dashboards() {
.filter((dashboard) => dashboard.has_draft)
.map((dashboard) => dashboard.name)
const selection = useSelection(dashboards.map((dashboard) => dashboard.name))
const remove = useDeleteSelected(
(dashboard) => DashboardsService.deleteDashboard({ name: dashboard }),
"dashboard",
() => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }),
)
return (
<div className="grid gap-6">
<div className="grid gap-1">
@@ -113,6 +125,8 @@ function Dashboards() {
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
selectedCount={selection.selected.length}
onDeleteSelected={() => setDeleteOpen(true)}
>
<Tooltip>
<TooltipTrigger asChild>
@@ -165,7 +179,25 @@ function Dashboards() {
</DialogContent>
</Dialog>
{dashboards.length === 0 ? (
<ConfirmDelete
open={deleteOpen}
onOpenChange={setDeleteOpen}
names={selection.selected}
noun="dashboard"
pending={remove.isPending}
onConfirm={() => {
setDeleteOpen(false)
remove.mutate(selection.selected)
}}
/>
{isPending ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-[5.5rem] rounded-lg" />
))}
</div>
) : dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
{needle
? "No dashboard matches that."
@@ -174,29 +206,25 @@ function Dashboards() {
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{dashboards.map((dashboard) => (
<Link
<OverviewCard
key={dashboard.name}
to="/dashboards/$name"
params={{ name: dashboard.name }}
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
data-testid="dashboard-card"
>
<span className="flex items-center gap-2">
<LayoutDashboard className="size-4 text-muted-foreground" />
{dashboard.title || dashboard.name}
{dashboard.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="text-sm text-muted-foreground">
{dashboard.widget_count} widget
{dashboard.widget_count === 1 ? "" : "s"} ·{" "}
{dashboard.page_count} page
{dashboard.page_count === 1 ? "" : "s"}
</span>
</Link>
link={{
to: "/dashboards/$name",
params: { name: dashboard.name },
}}
icon={LayoutDashboard}
title={dashboard.title || dashboard.name}
draft={dashboard.has_draft}
detail={`${dashboard.widget_count} widget${
dashboard.widget_count === 1 ? "" : "s"
} · ${dashboard.page_count} page${
dashboard.page_count === 1 ? "" : "s"
}`}
selecting={selection.selecting}
selected={selection.selected.includes(dashboard.name)}
onToggle={() => selection.toggle(dashboard.name)}
testId="dashboard-card"
/>
))}
</div>
)}
+50 -25
View File
@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
import { useState } from "react"
import { FlowsService } from "@/client"
import { OverviewCard, useSelection } from "@/components/Common/OverviewCard"
import {
ConfirmDelete,
OverviewToolbar,
useDeleteSelected,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
@@ -19,6 +22,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
@@ -31,13 +35,14 @@ export const Route = createFileRoute("/_layout/flows/")({
const NAME = /^[a-z][a-z0-9_]*$/
function Flows() {
const { data } = useQuery(flowsQueryOptions())
const { data, isPending } = useQuery(flowsQueryOptions())
const queryClient = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const [search, setSearch] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const create = useMutation({
mutationFn: (flow: string) =>
@@ -79,6 +84,13 @@ function Flows() {
.filter((flow) => flow.has_draft)
.map((flow) => flow.name)
const selection = useSelection(flows.map((flow) => flow.name))
const remove = useDeleteSelected(
(flow) => FlowsService.deleteFlow({ name: flow }),
"flow",
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
)
return (
<div className="grid gap-6">
<div className="grid gap-1">
@@ -100,6 +112,8 @@ function Flows() {
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
selectedCount={selection.selected.length}
onDeleteSelected={() => setDeleteOpen(true)}
/>
<DialogContent>
<form
@@ -136,7 +150,25 @@ function Flows() {
</DialogContent>
</Dialog>
{flows.length === 0 ? (
<ConfirmDelete
open={deleteOpen}
onOpenChange={setDeleteOpen}
names={selection.selected}
noun="flow"
pending={remove.isPending}
onConfirm={() => {
setDeleteOpen(false)
remove.mutate(selection.selected)
}}
/>
{isPending ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-[5.5rem] rounded-lg" />
))}
</div>
) : flows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{needle
? "No flow matches that."
@@ -145,29 +177,22 @@ function Flows() {
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{flows.map((flow) => (
<Link
<OverviewCard
key={flow.name}
to="/flows/$flowName"
params={{ flowName: flow.name }}
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
data-testid="flow-card"
>
<span className="flex items-center gap-2">
<Workflow className="size-4 text-muted-foreground" />
{flow.title || flow.name}
{flow.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="text-sm text-muted-foreground">
{flow.node_count ?? 0} node
{flow.node_count === 1 ? "" : "s"} ·{" "}
{flow.enabled === false ? "stopped" : "running"}
{flow.error_count ? ` · ${flow.error_count} to fix` : ""}
</span>
</Link>
link={{ to: "/flows/$flowName", params: { flowName: flow.name } }}
icon={Workflow}
title={flow.title || flow.name}
draft={flow.has_draft}
detail={`${flow.node_count ?? 0} node${
flow.node_count === 1 ? "" : "s"
} · ${flow.enabled === false ? "stopped" : "running"}${
flow.error_count ? ` · ${flow.error_count} to fix` : ""
}`}
selecting={selection.selecting}
selected={selection.selected.includes(flow.name)}
onToggle={() => selection.toggle(flow.name)}
testId="flow-card"
/>
))}
</div>
)}
+58 -23
View File
@@ -4,7 +4,9 @@ import { AlertCircle, Workflow } from "lucide-react"
import { useState } from "react"
import { type FlowSummary, FlowsService } from "@/client"
import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic"
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
import { BrainView } from "@/components/Flow/BrainView"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { HealthActivity } from "@/components/Health/HealthActivity"
@@ -30,6 +32,17 @@ export const Route = createFileRoute("/_layout/")({
/** Another tab can stop a flow, and the engine can fail one on its own. */
const REFRESH_INTERVAL = 10_000
/**
* How tall the two lists beside each other are allowed to get: about six flow
* rows, and whatever the mosaic fits in the same space. Past it each column
* scrolls on its own rather than pushing the health block off the screen.
*/
const LISTS = "grid max-h-96 grid-rows-[auto_minmax(0,1fr)] gap-2"
/** DESIGN-GUIDELINES.md → Typography, the canonical section header. */
const HEADER =
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
function FlowRow({ flow }: { flow: FlowSummary }) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
@@ -108,8 +121,11 @@ function Home() {
...flowsQueryOptions(),
refetchInterval: REFRESH_INTERVAL,
})
// Its own query beside the flows one, so neither list waits for the other.
const boards = useQuery(dashboardsQueryOptions())
const flows = data?.data ?? []
const flows = [...(data?.data ?? [])].sort(byRecency)
const dashboards = [...(boards.data?.data ?? [])].sort(byRecency)
return (
// `[&>*]:min-w-0`: a grid item's automatic minimum is its content, so one
@@ -126,28 +142,47 @@ function Home() {
rather than flow count: a flow made a minute ago has none. */}
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
<Card className="gap-0 py-0">
{isPending ? (
<div className="grid gap-3 p-5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<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">
<Workflow className="size-5" />
</span>
<p className="text-sm text-muted-foreground">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="text-sm font-medium underline">
Go to flows
</Link>
</div>
) : (
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
)}
</Card>
{/* One grid row holding both: a grid item stretches to the row, so the
two columns come out exactly as tall as each other whatever is in
them — and with one flow and one dashboard that is simply the taller
of the two, which is the floor the cap never goes under. */}
<div className="grid gap-6 [&>*]:min-w-0 lg:grid-cols-2">
<section className={LISTS}>
<h2 className={HEADER}>Flows</h2>
<Card className="gap-0 overflow-y-auto py-0">
{isPending ? (
<div className="grid gap-3 p-5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-28" />
</div>
) : flows.length === 0 ? (
<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">
<Workflow className="size-5" />
</span>
<p className="text-sm text-muted-foreground">
Flows you build show up here, with what they are doing.
</p>
<Link to="/flows" className="text-sm font-medium underline">
Go to flows
</Link>
</div>
) : (
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
)}
</Card>
</section>
<section className={LISTS}>
<h2 className={HEADER}>Dashboards</h2>
<Card className="gap-0 overflow-y-auto py-0">
<DashboardMosaic
dashboards={dashboards}
isPending={boards.isPending}
/>
</Card>
</section>
</div>
<HealthOverview range={range} onRangeChange={setRange} />
<HealthActivity range={range} />