Panels: per-device dashboard sets, paired by code
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

A panel is one screen and the ordered set of whole dashboards it shows, so a
hallway tablet and a workshop tablet carry different sets without either
dashboard knowing about the other. More than one and the device draws a rail
to switch between them — the same rail the editor puts on screen, because the
wall has it and it takes room off the canvas.

A screen has no keyboard, so it pairs rather than logs in: it shows a
six-character code, somebody approves it against a panel from the dashboards
overview, and the credential that mints reaches that panel's published
dashboards and the message endpoints its widgets speak, and nothing else.
Deleting the panel revokes it.

Closes the per-device view and the kiosk credential; supersedes the
multi-page/multi-section UI, since a page is now a dashboard of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
This commit is contained in:
2026-08-20 16:23:36 +02:00
co-authored by Claude Opus 5
parent eae6758c5a
commit c0ff1cd5c5
26 changed files with 1696 additions and 40 deletions
@@ -0,0 +1,275 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { Trash2 } from "lucide-react"
import { useState } from "react"
import {
type ApiError,
type PanelDef,
type PanelsConfig,
PanelsService,
} from "@/client"
import {
dashboardsQueryOptions,
panelsQueryOptions,
useSavePanels,
} from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
/** The store only accepts this shape, so say so before the request does. */
const slugify = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
/**
* Which dashboards hang on which screen, and adopting the screens themselves.
*
* A panel is a device rather than a document: it has no draft and nothing to
* publish, so it lives in a dialog over the dashboards list instead of a page
* of its own. Every change saves as it is made — there is no form to submit.
*/
export function PanelsDialog() {
const { data: config } = useQuery(panelsQueryOptions())
const { data: dashboards } = useQuery(dashboardsQueryOptions())
const save = useSavePanels()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const panels = config?.panels ?? []
const known = dashboards?.data ?? []
const write = (next: PanelsConfig) =>
save.mutate(next, {
onError: (error) => handleError.call(showErrorToast, error as ApiError),
})
const replace = (id: string, panel: PanelDef) =>
write({ panels: panels.map((p) => (p.id === id ? panel : p)) })
const newId = slugify(name)
const taken = panels.some((panel) => panel.id === newId)
return (
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
A panel is one screen and the dashboards it shows. Point the device at
the link, and it asks for a code you enter here.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6">
{panels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No panels yet. Add one below.
</p>
) : null}
{panels.map((panel) => (
<PanelRow
key={panel.id}
panel={panel}
dashboards={known.map((dashboard) => ({
name: dashboard.name,
title: dashboard.title || dashboard.name,
}))}
onChange={(next) => replace(panel.id, next)}
onRemove={() =>
write({ panels: panels.filter((p) => p.id !== panel.id) })
}
/>
))}
<Separator />
<form
className="flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault()
if (!newId || taken) return
write({ panels: [...panels, { id: newId, title: name.trim() }] })
setName("")
}}
>
<div className="grid flex-1 gap-1">
<label className="text-sm" htmlFor="new-panel">
New panel
</label>
<Input
id="new-panel"
value={name}
placeholder="hallway"
autoComplete="off"
data-testid="new-panel-name"
onChange={(event) => setName(event.target.value)}
/>
</div>
<Button
type="submit"
disabled={!newId || taken}
data-testid="add-panel"
>
Add panel
</Button>
</form>
{taken ? (
<p className="text-sm text-destructive">
There is already a panel called {newId}.
</p>
) : null}
</div>
</DialogContent>
)
}
function PanelRow({
panel,
dashboards,
onChange,
onRemove,
}: {
panel: PanelDef
dashboards: { name: string; title: string }[]
onChange: (next: PanelDef) => void
onRemove: () => void
}) {
const { showSuccessToast, showErrorToast } = useCustomToast()
const [code, setCode] = useState("")
const assigned = panel.dashboards ?? []
const pair = useMutation({
mutationFn: () =>
PanelsService.approvePairing({
panelId: panel.id,
requestBody: { code: code.trim().toUpperCase() },
}),
onSuccess: () => {
setCode("")
showSuccessToast(
"Paired — the screen switches over within a few seconds.",
)
},
onError: handleError.bind(showErrorToast),
})
const toggle = (dashboard: string) =>
onChange({
...panel,
// ponytail: order follows the order they were ticked. Arrows if anyone
// asks for them.
dashboards: assigned.includes(dashboard)
? assigned.filter((name) => name !== dashboard)
: [...assigned, dashboard],
})
const link = `${window.location.origin}/panel/${panel.id}`
return (
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
<div className="flex items-center gap-2">
<Input
value={panel.title}
placeholder={panel.id}
aria-label={`Title of ${panel.id}`}
onChange={(event) =>
onChange({ ...panel, title: event.target.value })
}
/>
<span className="shrink-0 text-sm text-muted-foreground">
{panel.id}
</span>
<Button
variant="ghost"
size="icon"
className="size-11 shrink-0 text-muted-foreground md:size-8"
aria-label={`Remove ${panel.id}`}
data-testid={`remove-panel-${panel.id}`}
onClick={onRemove}
>
<Trash2 />
</Button>
</div>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dashboards to assign yet.
</p>
) : (
<div className="grid gap-2">
{dashboards.map((dashboard) => {
const position = assigned.indexOf(dashboard.name)
const id = `assign-${panel.id}-${dashboard.name}`
return (
<label
key={dashboard.name}
htmlFor={id}
className="flex items-center gap-2 text-sm"
>
<Checkbox
id={id}
checked={position >= 0}
data-testid={id}
onCheckedChange={() => toggle(dashboard.name)}
/>
<span className="flex-1 truncate">{dashboard.title}</span>
{position >= 0 ? (
<span className="text-xs text-muted-foreground">
{position + 1}
</span>
) : null}
</label>
)
})}
</div>
)}
<div className="grid gap-2">
<Input
readOnly
value={link}
aria-label={`Link for ${panel.id}`}
className="text-muted-foreground"
onFocus={(event) => event.currentTarget.select()}
/>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
if (code.trim()) pair.mutate()
}}
>
<Input
value={code}
placeholder="Code shown on the screen"
aria-label={`Pairing code for ${panel.id}`}
autoComplete="off"
maxLength={6}
data-testid={`pair-code-${panel.id}`}
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<Button
type="submit"
variant="outline"
disabled={!code.trim() || pair.isPending}
data-testid={`pair-${panel.id}`}
>
Pair device
</Button>
</form>
</div>
</div>
)
}