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:
2026-08-20 16:23:36 +02:00
co-authored by Claude Opus 5
parent 7900eaebaa
commit ffae24c16b
26 changed files with 1696 additions and 40 deletions
+104
View File
@@ -1950,6 +1950,110 @@ export const PageDef_OutputSchema = {
description: 'One tab of a dashboard.'
} as const;
export const PairRequestSchema = {
properties: {
code: {
type: 'string',
title: 'Code'
}
},
type: 'object',
required: ['code'],
title: 'PairRequest'
} as const;
export const PairStartedSchema = {
properties: {
code: {
type: 'string',
title: 'Code'
},
secret: {
type: 'string',
title: 'Secret'
},
expires_in: {
type: 'integer',
title: 'Expires In',
default: 600
}
},
type: 'object',
required: ['code', 'secret'],
title: 'PairStarted',
description: 'What the device puts on the wall, and what it polls with.'
} as const;
export const PairStatusSchema = {
properties: {
access_token: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Access Token'
},
panel: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Panel'
}
},
type: 'object',
title: 'PairStatus',
description: 'Nothing yet, or the credential and the panel it is for.'
} as const;
export const PanelDefSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
dashboards: {
items: {
type: 'string'
},
type: 'array',
title: 'Dashboards'
}
},
type: 'object',
required: ['id'],
title: 'PanelDef',
description: 'One device, and what it shows.'
} as const;
export const PanelsConfigSchema = {
properties: {
panels: {
items: {
'$ref': '#/components/schemas/PanelDef'
},
type: 'array',
title: 'Panels'
}
},
type: 'object',
title: 'PanelsConfig',
description: 'Every panel this installation knows about.'
} as const;
export const PlacementSchema = {
properties: {
x: {
+136 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
export class AlertsService {
/**
@@ -1392,6 +1392,141 @@ export class ObservabilityService {
}
}
export class PanelsService {
/**
* Read Panels
* Every panel, and what each one shows.
* @returns PanelsConfig Successful Response
* @throws ApiError
*/
public static readPanels(): CancelablePromise<PanelsReadPanelsResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/panels/'
});
}
/**
* Save Panels
* Replace the panels. Takes effect on the devices' next read.
*
* A panel that disappears here takes its credential with it, so this is also
* how a device is unpaired.
* @param data The data for the request.
* @param data.requestBody
* @returns PanelsConfig Successful Response
* @throws ApiError
*/
public static savePanels(data: PanelsSavePanelsData): CancelablePromise<PanelsSavePanelsResponse> {
return __request(OpenAPI, {
method: 'PUT',
url: '/api/v1/panels/',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Start Pairing
* A device asks to be adopted. Unauthenticated, by necessity.
*
* All this hands out is a code that means nothing until somebody with an
* account approves it, so the worst an unwelcome caller achieves is a line in
* a dictionary that expires ten minutes later.
* @returns PairStarted Successful Response
* @throws ApiError
*/
public static startPairing(): CancelablePromise<PanelsStartPairingResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/panels/pair'
});
}
/**
* Poll Pairing
* Has anyone claimed this device yet?
*
* Answers the same 404 for a code that never existed, one that expired and
* one polled with the wrong secret: a caller reading a code off a wall learns
* nothing by guessing.
* @param data The data for the request.
* @param data.code
* @param data.secret
* @returns PairStatus Successful Response
* @throws ApiError
*/
public static pollPairing(data: PanelsPollPairingData): CancelablePromise<PanelsPollPairingResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/panels/pair/{code}',
path: {
code: data.code
},
query: {
secret: data.secret
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Approve Pairing
* Say which panel the device showing this code is.
*
* The credential names the approver, so what the panel does stays
* attributable to a person rather than to nobody.
* @param data The data for the request.
* @param data.panelId
* @param data.requestBody
* @returns Message Successful Response
* @throws ApiError
*/
public static approvePairing(data: PanelsApprovePairingData): CancelablePromise<PanelsApprovePairingResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/panels/{panel_id}/pair',
path: {
panel_id: data.panelId
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Panel
* One panel — read by a person, or by the device holding its credential.
*
* Declared after the pairing routes so a device polling ``/panels/pair/...``
* is never mistaken for someone reading a panel named ``pair``.
* @param data The data for the request.
* @param data.panelId
* @returns PanelDef Successful Response
* @throws ApiError
*/
public static readPanel(data: PanelsReadPanelData): CancelablePromise<PanelsReadPanelResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/panels/{panel_id}',
path: {
panel_id: data.panelId
},
errors: {
422: 'Validation Error'
}
});
}
}
export class PrivateService {
/**
* Create User
+67
View File
@@ -707,6 +707,43 @@ export type PageDef_Output = {
sections?: Array<SectionDef_Output>;
};
export type PairRequest = {
code: string;
};
/**
* What the device puts on the wall, and what it polls with.
*/
export type PairStarted = {
code: string;
secret: string;
expires_in?: number;
};
/**
* Nothing yet, or the credential and the panel it is for.
*/
export type PairStatus = {
access_token?: (string | null);
panel?: (string | null);
};
/**
* One device, and what it shows.
*/
export type PanelDef = {
id: string;
title?: string;
dashboards?: Array<(string)>;
};
/**
* Every panel this installation knows about.
*/
export type PanelsConfig = {
panels?: Array<PanelDef>;
};
/**
* Where a widget sits in its section's grid, in grid units.
*/
@@ -1356,6 +1393,36 @@ export type ObservabilityReadDeadLettersData = {
export type ObservabilityReadDeadLettersResponse = (Array<DeadLetter>);
export type PanelsReadPanelsResponse = (PanelsConfig);
export type PanelsSavePanelsData = {
requestBody: PanelsConfig;
};
export type PanelsSavePanelsResponse = (PanelsConfig);
export type PanelsStartPairingResponse = (PairStarted);
export type PanelsPollPairingData = {
code: string;
secret?: string;
};
export type PanelsPollPairingResponse = (PairStatus);
export type PanelsApprovePairingData = {
panelId: string;
requestBody: PairRequest;
};
export type PanelsApprovePairingResponse = (Message);
export type PanelsReadPanelData = {
panelId: string;
};
export type PanelsReadPanelResponse = (PanelDef);
export type PrivateCreateUserData = {
requestBody: PrivateUserCreate;
};
@@ -1,7 +1,7 @@
import { useMutation } from "@tanstack/react-query"
import { Check, Loader2, Plus, Search } from "lucide-react"
import { motion } from "motion/react"
import { useRef, useState } from "react"
import { type ReactNode, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { DialogTrigger } from "@/components/ui/dialog"
@@ -38,6 +38,7 @@ export function OverviewToolbar({
draftCount,
publishing,
onPublishAll,
children,
}: {
search: string
onSearch: (value: string) => void
@@ -49,6 +50,8 @@ export function OverviewToolbar({
draftCount: number
publishing: boolean
onPublishAll: () => void
/** Anything this particular overview adds, drawn ahead of the shared icons. */
children?: ReactNode
}) {
const [open, setOpen] = useState(false)
const trigger = useRef<HTMLButtonElement>(null)
@@ -62,6 +65,7 @@ export function OverviewToolbar({
return (
<div className="flex items-center justify-end gap-1">
{children}
{open ? (
<motion.div
initial={{ width: 0, opacity: 0 }}
@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Check,
@@ -68,9 +68,11 @@ import {
sectionsOf,
widgetsOf,
} from "./DashboardView"
import { PanelRail, RAIL_INSET } from "./PanelRail"
import { DashboardPanel, WidgetPanel } from "./panels"
import {
dashboardKeys,
panelsQueryOptions,
useDiscardDashboardDraft,
usePublishDashboard,
useSaveDashboard,
@@ -351,6 +353,18 @@ export function DashboardEditor({
const active = widgets.find((widget) => widget.id === selected) ?? null
const panelOpen = Boolean(active) || settingsOpen
// A dashboard hanging on a panel beside others is drawn with a rail over it,
// which takes room off the canvas. Show that here rather than letting someone
// arrange against a width the wall does not have.
// ponytail: the first such panel wins — the others differ only in scale.
const { data: panels } = useQuery(panelsQueryOptions())
const railDashboards =
(panels?.panels ?? []).find(
(panel) =>
(panel.dashboards ?? []).length > 1 &&
(panel.dashboards ?? []).includes(draft.name),
)?.dashboards ?? null
const setEdit = (next: boolean) => {
setSelected(null)
setSettingsOpen(false)
@@ -442,11 +456,28 @@ export function DashboardEditor({
return (
<>
{railDashboards && !stacked ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are the
// same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null}
<div
className={cn(
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
)}
style={
railDashboards && !stacked ? { paddingLeft: RAIL_INSET } : undefined
}
data-testid="dashboard-canvas"
>
{body}
@@ -457,6 +488,7 @@ export function DashboardEditor({
"pointer-events-none absolute inset-0 transition-[right] duration-200",
panelOpen && "md:right-[27rem]",
)}
style={railDashboards && !stacked ? { left: RAIL_INSET } : undefined}
>
<CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium">
@@ -0,0 +1,94 @@
import { useQueries } from "@tanstack/react-query"
import { Link, type LinkProps } from "@tanstack/react-router"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
/** How much room the rail takes, canvas insets included. Mirrors the side
* panel's `27rem`: the button plus the gutters either side of it. */
export const RAIL_INSET = "4.5rem"
/** Two letters off the title, so a rail of four reads as four different things. */
function initials(label: string): string {
const words = label.split(/[\s_-]+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
}
/**
* Switching between the dashboards one panel was assigned.
*
* Permanent rather than a menu behind a button: a wall panel is glanced at,
* and something you have to open first is something nobody opens. It costs
* about a fortieth of the canvas, which `CanvasSurface` absorbs by scaling —
* the grid itself never changes, so an arrangement made without the rail still
* fits with it.
*
* Reading the dashboards it links to is also what fills the labels, and it
* warms the cache for the neighbours so a switch draws immediately.
*/
export function PanelRail({
dashboards,
current,
linkFor,
className,
}: {
dashboards: string[]
current: string
linkFor: (name: string) => LinkProps
className?: string
}) {
const titles = useQueries({
queries: dashboards.map((name) => dashboardQueryOptions(name)),
combine: (results) =>
results.map((result, index) => result.data?.title || dashboards[index]),
})
return (
<nav
aria-label="Dashboards on this panel"
data-testid="panel-rail"
className={cn(
"pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 overflow-y-auto rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md",
className,
)}
>
{dashboards.map((name, index) => {
const label = titles[index]
const active = name === current
return (
<Tooltip key={name}>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
// `icon-lg` at every width on purpose: a rail is a touch
// surface whatever the screen's size, and the panel it hangs
// on is 1024 wide as often as it is 400.
size="icon-lg"
className={cn(
"shrink-0 text-xs font-medium text-muted-foreground",
active && "bg-accent text-foreground",
)}
aria-current={active ? "page" : undefined}
data-testid={`panel-rail-${name}`}
>
<Link {...linkFor(name)} aria-label={label}>
{initials(label)}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
)
})}
</nav>
)
}
@@ -0,0 +1,32 @@
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import {
CanvasSurface,
DashboardView,
} from "@/components/Dashboard/DashboardView"
/**
* One dashboard filling whatever screen it landed on.
*
* The whole of what a wall panel draws, shared by the single-dashboard route
* (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows
* the same thing either way — the second merely has a rail beside it.
*/
export function PanelSurface({
dashboard,
stacked,
}: {
dashboard: Dashboard
/** 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. */
stacked?: boolean
}) {
if (stacked) return <DashboardView dashboard={dashboard} stacked />
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
return (
<CanvasSurface dashboard={dashboard}>
{() => <DashboardView dashboard={dashboard} />}
</CanvasSurface>
)
}
@@ -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>
)
}
@@ -4,6 +4,8 @@ import {
type DashboardDef_Input,
DashboardsService,
MessagesService,
type PanelsConfig,
PanelsService,
} from "@/client"
export const dashboardKeys = {
@@ -31,6 +33,41 @@ export const dashboardQueryOptions = (name: string, draft = false) => ({
queryFn: () => DashboardsService.readDashboard({ name, draft }),
})
export const panelKeys = {
all: ["panels"] as const,
detail: (id: string) => ["panels", id] as const,
}
/** Every panel and what it shows. Read by the editor to know if a rail is due. */
export const panelsQueryOptions = () => ({
queryKey: panelKeys.all,
queryFn: () => PanelsService.readPanels(),
})
/**
* One panel, as the device hanging on the wall reads it.
*
* A panel credential may read exactly this and the dashboards it names, so
* this is the query the panel route is built on rather than the list above.
*/
export const panelQueryOptions = (id: string) => ({
queryKey: panelKeys.detail(id),
queryFn: () => PanelsService.readPanel({ panelId: id }),
})
/** Replace the panels. Superuser-only on the server. */
export function useSavePanels() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: PanelsConfig) =>
PanelsService.savePanels({ requestBody: body }),
onSuccess: (saved) => {
queryClient.setQueryData(panelKeys.all, saved)
queryClient.invalidateQueries({ queryKey: panelKeys.all })
},
})
}
/** Every message any flow declares — what a widget can be pointed at. */
export const messageCatalogQueryOptions = () => ({
queryKey: dashboardKeys.messages,
@@ -11,6 +11,10 @@ import { slideUp, transitions } from "@/lib/motion"
* what the overviews are for, so this bar carries the name and nothing that
* takes you away from it. Shared by the flow editor and the dashboards so the
* two shells read as one.
*
* The one exception sits beside it rather than in it: a dashboard assigned to
* a panel alongside others is edited with that panel's rail on screen, because
* the wall has it too and it takes room off the canvas.
*/
export function CanvasTitle({ children }: { children: ReactNode }) {
return (
+11
View File
@@ -39,6 +39,17 @@ const handleApiError = (error: Error) => {
return
}
if (isAuthFailure(error)) {
// A paired wall panel has no login screen to go back to — it asks for a
// new code instead. Only a 401 is worth throwing its credential away for:
// a 403 there is a dashboard it was just unassigned from, which the next
// read of the panel corrects on its own.
if (window.location.pathname.startsWith("/panel")) {
if (error instanceof ApiError && error.status === 401) {
localStorage.removeItem("access_token")
window.location.href = "/panel"
}
return
}
if (portal) {
// The portal knows whether they are still signed in; it can mint a fresh
// handoff or send them to the login screen.
+42
View File
@@ -15,8 +15,10 @@ import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
import { Route as LoginRouteImport } from './routes/login'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as CanvasRouteImport } from './routes/_canvas'
import { Route as PanelIndexRouteImport } from './routes/panel.index'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as ViewNameRouteImport } from './routes/view.$name'
import { Route as PanelIdRouteImport } from './routes/panel.$id'
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
@@ -56,6 +58,11 @@ const CanvasRoute = CanvasRouteImport.update({
id: '/_canvas',
getParentRoute: () => rootRouteImport,
} as any)
const PanelIndexRoute = PanelIndexRouteImport.update({
id: '/panel/',
path: '/panel/',
getParentRoute: () => rootRouteImport,
} as any)
const LayoutIndexRoute = LayoutIndexRouteImport.update({
id: '/',
path: '/',
@@ -66,6 +73,11 @@ const ViewNameRoute = ViewNameRouteImport.update({
path: '/view/$name',
getParentRoute: () => rootRouteImport,
} as any)
const PanelIdRoute = PanelIdRouteImport.update({
id: '/panel/$id',
path: '/panel/$id',
getParentRoute: () => rootRouteImport,
} as any)
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
id: '/oauth/authorize',
path: '/oauth/authorize',
@@ -129,7 +141,9 @@ export interface FileRoutesByFullPath {
'/secrets': typeof LayoutSecretsRoute
'/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/panel/$id': typeof PanelIdRoute
'/view/$name': typeof ViewNameRoute
'/panel/': typeof PanelIndexRoute
'/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/dashboards/': typeof LayoutDashboardsIndexRoute
@@ -147,7 +161,9 @@ export interface FileRoutesByTo {
'/secrets': typeof LayoutSecretsRoute
'/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/panel/$id': typeof PanelIdRoute
'/view/$name': typeof ViewNameRoute
'/panel': typeof PanelIndexRoute
'/dashboards/$name': typeof CanvasDashboardsNameRoute
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/dashboards': typeof LayoutDashboardsIndexRoute
@@ -167,8 +183,10 @@ export interface FileRoutesById {
'/_layout/secrets': typeof LayoutSecretsRoute
'/_layout/settings': typeof LayoutSettingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/panel/$id': typeof PanelIdRoute
'/view/$name': typeof ViewNameRoute
'/_layout/': typeof LayoutIndexRoute
'/panel/': typeof PanelIndexRoute
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
@@ -188,7 +206,9 @@ export interface FileRouteTypes {
| '/secrets'
| '/settings'
| '/oauth/authorize'
| '/panel/$id'
| '/view/$name'
| '/panel/'
| '/dashboards/$name'
| '/flows/$flowName'
| '/dashboards/'
@@ -206,7 +226,9 @@ export interface FileRouteTypes {
| '/secrets'
| '/settings'
| '/oauth/authorize'
| '/panel/$id'
| '/view/$name'
| '/panel'
| '/dashboards/$name'
| '/flows/$flowName'
| '/dashboards'
@@ -225,8 +247,10 @@ export interface FileRouteTypes {
| '/_layout/secrets'
| '/_layout/settings'
| '/oauth/authorize'
| '/panel/$id'
| '/view/$name'
| '/_layout/'
| '/panel/'
| '/_canvas/dashboards/$name'
| '/_canvas/flows/$flowName'
| '/_layout/dashboards/'
@@ -241,7 +265,9 @@ export interface RootRouteChildren {
ResetPasswordRoute: typeof ResetPasswordRoute
SignupRoute: typeof SignupRoute
OauthAuthorizeRoute: typeof OauthAuthorizeRoute
PanelIdRoute: typeof PanelIdRoute
ViewNameRoute: typeof ViewNameRoute
PanelIndexRoute: typeof PanelIndexRoute
}
declare module '@tanstack/react-router' {
@@ -288,6 +314,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CanvasRouteImport
parentRoute: typeof rootRouteImport
}
'/panel/': {
id: '/panel/'
path: '/panel'
fullPath: '/panel/'
preLoaderRoute: typeof PanelIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_layout/': {
id: '/_layout/'
path: '/'
@@ -302,6 +335,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ViewNameRouteImport
parentRoute: typeof rootRouteImport
}
'/panel/$id': {
id: '/panel/$id'
path: '/panel/$id'
fullPath: '/panel/$id'
preLoaderRoute: typeof PanelIdRouteImport
parentRoute: typeof rootRouteImport
}
'/oauth/authorize': {
id: '/oauth/authorize'
path: '/oauth/authorize'
@@ -421,7 +461,9 @@ const rootRouteChildren: RootRouteChildren = {
ResetPasswordRoute: ResetPasswordRoute,
SignupRoute: SignupRoute,
OauthAuthorizeRoute: OauthAuthorizeRoute,
PanelIdRoute: PanelIdRoute,
ViewNameRoute: ViewNameRoute,
PanelIndexRoute: PanelIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
@@ -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"
+93
View File
@@ -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>
)
}
+92
View File
@@ -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>
)
}
+14 -21
View File
@@ -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>
)
}