import { Badge, Button, Field, 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;
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
Open display setup on the screen that will show signage.
-
2
Enter its six-digit code under Display devices in the Dashboard sidebar.
-
3
Name the display and choose its audience channel below.
{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) => (
{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 (
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,
})}
);
}