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
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
||||
import { LayoutDashboard } from "lucide-react"
|
||||
import { LayoutDashboard, MonitorSmartphone } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { DashboardsService } from "@/client"
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
OverviewToolbar,
|
||||
usePublishAll,
|
||||
} from "@/components/Common/OverviewToolbar"
|
||||
import { PanelsDialog } from "@/components/Dashboard/PanelsDialog"
|
||||
import {
|
||||
dashboardKeys,
|
||||
dashboardsQueryOptions,
|
||||
@@ -22,6 +23,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
@@ -37,6 +43,7 @@ function Dashboards() {
|
||||
const [name, setName] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [panelsOpen, setPanelsOpen] = useState(false)
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (dashboard: string) =>
|
||||
@@ -92,6 +99,12 @@ function Dashboards() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Its own root rather than a nested one: which screens exist is a
|
||||
different question from which dashboards do. */}
|
||||
<Dialog open={panelsOpen} onOpenChange={setPanelsOpen}>
|
||||
<PanelsDialog />
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<OverviewToolbar
|
||||
search={search}
|
||||
@@ -103,7 +116,23 @@ function Dashboards() {
|
||||
draftCount={drafts.length}
|
||||
publishing={publishAll.isPending}
|
||||
onPublishAll={() => publishAll.mutate(drafts)}
|
||||
/>
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
aria-label="Panels"
|
||||
data-testid="open-panels"
|
||||
onClick={() => setPanelsOpen(true)}
|
||||
>
|
||||
<MonitorSmartphone />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Panels</TooltipContent>
|
||||
</Tooltip>
|
||||
</OverviewToolbar>
|
||||
<DialogContent>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import { PanelRail, RAIL_INSET } from "@/components/Dashboard/PanelRail"
|
||||
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
|
||||
import {
|
||||
dashboardQueryOptions,
|
||||
panelQueryOptions,
|
||||
} from "@/components/Dashboard/queries"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* What a paired device shows: the dashboards this panel was assigned.
|
||||
*
|
||||
* The same full-bleed surface as `/view/{name}`, with a rail down the left
|
||||
* when there is more than one to switch between — so a hallway tablet and a
|
||||
* workshop tablet can carry different sets without either dashboard knowing
|
||||
* anything about the other.
|
||||
*
|
||||
* Which one is open lives in the URL, so a panel that reboots comes back where
|
||||
* it was rather than at the first one.
|
||||
*/
|
||||
export const Route = createFileRoute("/panel/$id")({
|
||||
component: PanelRoute,
|
||||
validateSearch: (search: Record<string, unknown>): { d?: string } =>
|
||||
typeof search.d === "string" ? { d: search.d } : {},
|
||||
beforeLoad: async () => {
|
||||
// Unpaired, or a credential the server stopped honouring. Either way this
|
||||
// device needs a new code, not a login form it cannot type into.
|
||||
if (!isLoggedIn()) {
|
||||
throw redirect({ to: "/panel" })
|
||||
}
|
||||
},
|
||||
head: ({ params }) => ({ meta: [{ title: `${params.id} - Fluksio` }] }),
|
||||
})
|
||||
|
||||
function PanelRoute() {
|
||||
const { id } = Route.useParams()
|
||||
const { d } = Route.useSearch()
|
||||
useFlowSocket()
|
||||
|
||||
const { data: panel } = useQuery(panelQueryOptions(id))
|
||||
const dashboards = panel?.dashboards ?? []
|
||||
// A name in the URL that this panel no longer carries falls back to the
|
||||
// first, which is what a dashboard removed from under a running panel does.
|
||||
const current = d && dashboards.includes(d) ? d : dashboards[0]
|
||||
|
||||
const { data: dashboard } = useQuery({
|
||||
...dashboardQueryOptions(current ?? ""),
|
||||
enabled: Boolean(current),
|
||||
})
|
||||
const stacked = useIsMobile()
|
||||
const rail = dashboards.length > 1
|
||||
|
||||
if (panel && dashboards.length === 0) {
|
||||
return (
|
||||
<main className="grid h-svh w-full place-items-center p-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No dashboards are assigned to this panel yet.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
"relative h-svh w-full p-4",
|
||||
stacked ? "overflow-y-auto" : "overflow-hidden",
|
||||
)}
|
||||
style={rail ? { paddingLeft: RAIL_INSET } : undefined}
|
||||
>
|
||||
{rail && current ? (
|
||||
<PanelRail
|
||||
dashboards={dashboards}
|
||||
current={current}
|
||||
linkFor={(name) => ({
|
||||
to: "/panel/$id",
|
||||
params: { id },
|
||||
search: { d: name },
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{dashboard ? (
|
||||
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -2,21 +2,22 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import {
|
||||
CanvasSurface,
|
||||
DashboardView,
|
||||
} from "@/components/Dashboard/DashboardView"
|
||||
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
|
||||
/**
|
||||
* What a wall panel is pointed at.
|
||||
* What a wall panel is pointed at when it shows one dashboard and nothing else.
|
||||
*
|
||||
* Deliberately outside both shells: no sidebar, no footer, no editing, and no
|
||||
* column cap — the widgets are the whole page. Route splitting means a panel
|
||||
* never downloads the editor or the grid library either.
|
||||
*
|
||||
* A device that was paired to a panel uses `/panel/{id}` instead, which is the
|
||||
* same surface with a rail for switching between the dashboards it was
|
||||
* assigned. This route stays for a browser tab pointed straight at one.
|
||||
*/
|
||||
export const Route = createFileRoute("/view/$name")({
|
||||
component: PanelView,
|
||||
@@ -32,27 +33,19 @@ function PanelView() {
|
||||
const { name } = Route.useParams()
|
||||
useFlowSocket()
|
||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||
// A landscape arrangement scaled onto a phone comes out at about a fifth of
|
||||
// its size, which reads as nothing at all. Stack it instead.
|
||||
const stacked = useIsMobile()
|
||||
|
||||
if (!dashboard) return <main className="h-svh w-full" />
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<main className="h-svh w-full overflow-y-auto p-4">
|
||||
<DashboardView dashboard={dashboard as Dashboard} stacked />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="h-svh w-full overflow-hidden p-4">
|
||||
{/* The panel's own surface, scaled to whatever screen it landed on. No
|
||||
dots: nothing is being arranged here. */}
|
||||
<CanvasSurface dashboard={dashboard as Dashboard}>
|
||||
{() => <DashboardView dashboard={dashboard as Dashboard} />}
|
||||
</CanvasSurface>
|
||||
<main
|
||||
className={
|
||||
stacked
|
||||
? "h-svh w-full overflow-y-auto p-4"
|
||||
: "h-svh w-full overflow-hidden p-4"
|
||||
}
|
||||
>
|
||||
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user