Files
app/frontend/src/routes/panel.index.tsx
T
stroblmeandClaude Opus 5 c0ff1cd5c5
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
Panels: per-device dashboard sets, paired by code
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
2026-08-20 16:23:36 +02:00

93 lines
3.3 KiB
TypeScript

import { useMutation, useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { useEffect } from "react"
import { PanelsService } from "@/client"
/**
* Adopting a screen that has no keyboard.
*
* A wall tablet cannot be asked to type an email address and a password, so it
* asks for a code instead and shows it. Somebody with an account types that
* code into the panels dialog to say which panel this device is, and the
* credential it mints arrives here on the next poll.
*
* Outside both shells like `/view/{name}`: until it is paired, this device has
* no session and there is nothing to put around it.
*/
export const Route = createFileRoute("/panel/")({
component: PairPanel,
head: () => ({ meta: [{ title: "Pair this panel - Fluksio" }] }),
})
function PairPanel() {
// A code lives ten minutes. Asking for one is the mount, and asking again is
// what happens when this one is no longer recognised.
const start = useMutation({ mutationFn: () => PanelsService.startPairing() })
const request = start.mutate
// biome-ignore lint/correctness/useExhaustiveDependencies: asked for once, when the screen goes up.
useEffect(() => {
request()
}, [])
const started = start.data
const { data: status, error } = useQuery({
queryKey: ["pairing", started?.code],
queryFn: () =>
PanelsService.pollPairing({
code: started?.code ?? "",
secret: started?.secret ?? "",
}),
enabled: Boolean(started),
refetchInterval: 3000,
// A 404 means this code is finished, not that the request failed.
retry: false,
})
// Expired, or collected already. Ask for another rather than leaving a
// number on the wall that no longer works.
useEffect(() => {
if (error) request()
}, [error, request])
useEffect(() => {
if (!status?.access_token || !status.panel) return
localStorage.setItem("access_token", status.access_token)
// A full load rather than a route change: everything this page asked for
// was asked without a credential, and the socket has to dial again holding
// this one.
window.location.href = `/panel/${status.panel}`
}, [status])
return (
<main className="grid h-svh w-full place-items-center p-8">
<div className="grid max-w-md gap-6 text-center">
<div className="grid gap-2">
<h1 className="text-2xl">Pair this panel</h1>
<p className="text-sm text-muted-foreground">
On a computer, open <span className="font-medium">Dashboards</span>{" "}
<span className="font-medium">Panels</span>, pick which panel this
is, and enter this code.
</p>
</div>
<p className="font-mono text-6xl font-medium tracking-[0.2em]">
<span data-testid="pairing-code" aria-hidden="true">
{started?.code ?? "······"}
</span>
{/* Spelled out, so a reader says the characters rather than trying
to pronounce them as a word. */}
<span className="sr-only">
{(started?.code ?? "").split("").join(" ")}
</span>
</p>
<p className="text-sm text-muted-foreground">
The code lasts ten minutes; this screen replaces it when it runs out.
</p>
</div>
</main>
)
}