import { Badge, Button, Field, InlineNotice, Panel, Select, TextInput, } from "@ekklesia/ui"; import { type ReactElement, useState } from "react"; import type { SignageApi } from "../api.js"; import { useAction, useResource, useTick } from "../hooks.js"; import type { ChannelGroupView, ViewerHealthView } from "../types.js"; import { FailureNote, formatSince, OnlineBadge, renderLoadable, } from "./shared.js"; const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; function DisplayRow({ canManage, groups, nowMs, onChannelChange, onNameChange, viewer, }: { readonly canManage: boolean; readonly groups: readonly ChannelGroupView[]; readonly nowMs: number; readonly onChannelChange: (channelId: string) => void; readonly onNameChange: (name: string) => void; readonly viewer: ViewerHealthView; }): ReactElement { return (
{canManage ? ( event.target.value !== viewer.name && onNameChange(event.target.value) } /> ) : ( {viewer.name} )}
{canManage ? ( {(control) => ( )} ) : undefined}
{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`} {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"} {viewer.lastError === null ? undefined : ( {viewer.lastError.code} )}
); } /** * Channel groups, audience channels, and displays, presented in the same hierarchy they use at run * time: one group clock, one audience variant per channel, then the screens assigned to it. */ export function DisplaysPanel({ api, canManage, copyText, displaySetupUrl, onGroupsChanged, }: { readonly api: SignageApi; readonly canManage: boolean; readonly copyText: (value: string) => Promise; readonly displaySetupUrl: string | undefined; readonly onGroupsChanged?: () => void; }): ReactElement { const groups = useResource( () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), [], ); const viewers = useResource( () => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), [], ); const edit = useAction(() => { groups.reload(); viewers.reload(); onGroupsChanged?.(); }); const viewerEdit = useAction(viewers.reload); const [groupName, setGroupName] = useState(""); const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">( "idle", ); const [confirmGroupId, setConfirmGroupId] = useState( undefined, ); const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string; }>({ groupId: "", key: "", name: "", }); useTick(5000); const nowMs = Date.now(); const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : []; const updateViewer = ( viewerId: string, changes: Record, ): void => { viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes)); }; return (
Open display setup
) } description="Connect a screen in three short steps." title="Pair a display" >
  1. 1 Open display setup on the screen that will show signage.
  2. 2 Enter its six-digit code under Display devices in the Dashboard sidebar.
  3. 3 Name the display and choose its audience channel below.
{displaySetupUrl === undefined ? ( This host did not provide a display setup address. ) : undefined} {copyStatus === "copied" ? "Display setup URL copied." : copyStatus === "failed" ? "Could not copy the URL. Copy it from the newly opened setup page." : ""} {viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? (
No displays paired yet A newly approved screen appears below in its assigned channel.
) : undefined} {(control) => ( setGroupName(event.target.value)} placeholder="Nursery hallway" value={groupName} /> )} ) : undefined } description="A channel group shares one playback clock. Each audience channel can receive a different content variant." title="Channel groups and displays" > {renderLoadable( groups.state, (value) => { const knownChannelIds = new Set( value.groups.flatMap((group) => group.channels.map((channel) => channel.id), ), ); const unassigned = readyViewers.filter( (viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId), ); return (
{value.groups.map((group) => (
Channel group

{group.name}

{group.playback.transport} {canManage && value.groups.length > 1 ? ( ) : canManage ? ( Required group ) : undefined}
{confirmGroupId === group.id ? (

Delete {group.name}? Its playback history will be removed. Displays assigned here will move to the default channel of another group.

) : undefined}
Audience channels

Who this group renders for

Displays inherit the channel they are assigned to.
{group.channels.map((channel) => { const assigned = readyViewers.filter( (viewer) => viewer.channelId === channel.id, ); return (
{channel.name}
{channel.isDefault ? ( Default channel ) : undefined} Audience key {channel.key} {`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
{canManage && !channel.isDefault ? ( ) : undefined}
Displays {assigned.length === 0 ? (

No displays assigned.

) : ( assigned.map((viewer) => ( updateViewer(viewer.id, { channelId }) } onNameChange={(name) => updateViewer(viewer.id, { name }) } viewer={viewer} /> )) )}
); })}
{canManage ? (
Add an audience channel
The default channel always remains available.
{(control) => ( setChannelDraft((current) => ({ groupId: group.id, key: current.groupId === group.id ? current.key : "", name: event.target.value, })) } placeholder="Online audience" value={ channelDraft.groupId === group.id ? channelDraft.name : "" } /> )} {(control) => ( setChannelDraft((current) => ({ groupId: group.id, key: event.target.value, name: current.groupId === group.id ? current.name : "", })) } pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" placeholder="online" value={ channelDraft.groupId === group.id ? channelDraft.key : "" } /> )}
) : undefined}
))} {unassigned.length === 0 ? undefined : (
Needs assignment {unassigned.map((viewer) => ( updateViewer(viewer.id, { channelId }) } onNameChange={(name) => updateViewer(viewer.id, { name }) } viewer={viewer} /> ))}
)}
); }, { label: "Loading channel groups…", onRetry: groups.reload }, )} {renderLoadable(viewers.state, () => null, { label: "Loading displays…", onRetry: viewers.reload, })}
); }