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) => (
onChannelChange(event.target.value)}
value={viewer.channelId ?? ""}
>
{groups.map((group) => (
{group.channels.map((channel) => (
{channel.name}
))}
))}
)}
) : 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
{
setCopyStatus("idle");
void copyText(displaySetupUrl).then(
() => setCopyStatus("copied"),
() => setCopyStatus("failed"),
);
}}
>
Copy URL
)
}
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.
{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}
/>
)}
edit.run(async () => {
await api.send("POST", "channel-groups", {
name: groupName.trim(),
});
setGroupName("");
})
}
variant="primary"
>
Create group
) : 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.
setConfirmGroupId(undefined)}
>
Cancel
edit.run(async () => {
await api.send(
"DELETE",
`channel-groups/${group.id}`,
);
setConfirmGroupId(undefined);
})
}
variant="danger"
>
Confirm delete
) : 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
: ""
}
/>
)}
edit.run(async () => {
await api.send(
"POST",
`channel-groups/${group.id}/channels`,
{
key: channelDraft.key.trim(),
name: channelDraft.name.trim(),
},
);
setChannelDraft({
groupId: "",
key: "",
name: "",
});
})
}
variant="primary"
>
Add channel
) : 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,
})}
);
}