430 lines
18 KiB
TypeScript
430 lines
18 KiB
TypeScript
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 (
|
|
<div className="signage-display-row" data-testid={`signage-display-${viewer.id}`}>
|
|
<div className="signage-display-row__identity">
|
|
{canManage ? (
|
|
<TextInput
|
|
aria-label={`Name for ${viewer.name}`}
|
|
defaultValue={viewer.name}
|
|
onBlur={(event) => event.target.value !== viewer.name && onNameChange(event.target.value)}
|
|
/>
|
|
) : (
|
|
<strong>{viewer.name}</strong>
|
|
)}
|
|
<OnlineBadge online={viewer.online} />
|
|
</div>
|
|
<div className="signage-display-row__assignment">
|
|
{canManage ? (
|
|
<Field label="Audience channel">
|
|
{(control) => (
|
|
<Select
|
|
{...control}
|
|
aria-label={`Channel for ${viewer.name}`}
|
|
onChange={(event) => onChannelChange(event.target.value)}
|
|
value={viewer.channelId ?? ""}
|
|
>
|
|
{groups.map((group) => (
|
|
<optgroup key={group.id} label={group.name}>
|
|
{group.channels.map((channel) => (
|
|
<option key={channel.id} value={channel.id}>
|
|
{channel.name}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
))}
|
|
</Select>
|
|
)}
|
|
</Field>
|
|
) : undefined}
|
|
</div>
|
|
<div className="signage-display-row__health">
|
|
<span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span>
|
|
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
|
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"}
|
|
</Badge>
|
|
{viewer.lastError === null ? undefined : (
|
|
<span className="signage-display-row__error" title={viewer.lastError.detail ?? undefined}>
|
|
{viewer.lastError.code}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<void>;
|
|
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<string | undefined>(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<string, string>): void => {
|
|
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
|
|
};
|
|
|
|
return (
|
|
<div className="signage-columns">
|
|
<Panel
|
|
actions={
|
|
<div className="signage-pairing-actions">
|
|
<a
|
|
className="ek-button ek-focusable ek-button--primary"
|
|
href={displaySetupUrl}
|
|
rel="noopener noreferrer"
|
|
target="_blank"
|
|
>
|
|
Open display setup
|
|
</a>
|
|
<Button
|
|
onClick={() => {
|
|
setCopyStatus("idle");
|
|
void copyText(displaySetupUrl).then(
|
|
() => setCopyStatus("copied"),
|
|
() => setCopyStatus("failed"),
|
|
);
|
|
}}
|
|
>
|
|
Copy URL
|
|
</Button>
|
|
</div>
|
|
}
|
|
description="Connect a screen in three short steps."
|
|
title="Pair a display"
|
|
>
|
|
<ol className="signage-pairing-steps" aria-label="Display pairing steps">
|
|
<li>
|
|
<span className="signage-pairing-steps__number">1</span>
|
|
<span>Open display setup on the screen that will show signage.</span>
|
|
</li>
|
|
<li>
|
|
<span className="signage-pairing-steps__number">2</span>
|
|
<span>Enter its six-digit code under Display devices in the Dashboard sidebar.</span>
|
|
</li>
|
|
<li>
|
|
<span className="signage-pairing-steps__number">3</span>
|
|
<span>Name the display and choose its audience channel below.</span>
|
|
</li>
|
|
</ol>
|
|
<span className="signage-copy-status" aria-live="polite">
|
|
{copyStatus === "copied"
|
|
? "Display setup URL copied."
|
|
: copyStatus === "failed"
|
|
? "Could not copy the URL. Copy it from the newly opened setup page."
|
|
: ""}
|
|
</span>
|
|
{viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? (
|
|
<div className="signage-display-empty">
|
|
<strong>No displays paired yet</strong>
|
|
<span>A newly approved screen appears below in its assigned channel.</span>
|
|
</div>
|
|
) : undefined}
|
|
</Panel>
|
|
|
|
<Panel
|
|
actions={
|
|
canManage ? (
|
|
<div className="signage-inline-form signage-new-group">
|
|
<Field label="New channel group">
|
|
{(control) => (
|
|
<TextInput
|
|
{...control}
|
|
onChange={(event) => setGroupName(event.target.value)}
|
|
placeholder="Nursery hallway"
|
|
value={groupName}
|
|
/>
|
|
)}
|
|
</Field>
|
|
<Button
|
|
disabled={edit.pending || groupName.trim().length === 0}
|
|
onClick={() =>
|
|
edit.run(async () => {
|
|
await api.send("POST", "channel-groups", {
|
|
name: groupName.trim(),
|
|
});
|
|
setGroupName("");
|
|
})
|
|
}
|
|
variant="primary"
|
|
>
|
|
Create group
|
|
</Button>
|
|
</div>
|
|
) : 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 (
|
|
<div className="signage-groups" data-testid="signage-groups">
|
|
{value.groups.map((group) => (
|
|
<article className="signage-group" key={group.id}>
|
|
<header className="signage-group__header">
|
|
<div className="signage-group__identity">
|
|
<span className="signage-hierarchy-label">Channel group</span>
|
|
<h3>{group.name}</h3>
|
|
</div>
|
|
<Badge tone="info">{group.playback.transport}</Badge>
|
|
{canManage && value.groups.length > 1 ? (
|
|
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(group.id)} variant="danger">
|
|
Delete group
|
|
</Button>
|
|
) : canManage ? (
|
|
<Badge tone="neutral">Required group</Badge>
|
|
) : undefined}
|
|
</header>
|
|
{confirmGroupId === group.id ? (
|
|
<div className="signage-confirm" role="alert">
|
|
<p>
|
|
Delete {group.name}? Its playback history will be removed. Displays assigned here will move to
|
|
the default channel of another group.
|
|
</p>
|
|
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
disabled={edit.pending}
|
|
onClick={() =>
|
|
edit.run(async () => {
|
|
await api.send("DELETE", `channel-groups/${group.id}`);
|
|
setConfirmGroupId(undefined);
|
|
})
|
|
}
|
|
variant="danger"
|
|
>
|
|
Confirm delete
|
|
</Button>
|
|
</div>
|
|
) : undefined}
|
|
|
|
<section className="signage-audiences">
|
|
<div className="signage-audiences__heading">
|
|
<div>
|
|
<span className="signage-hierarchy-label">Audience channels</span>
|
|
<h4>Who this group renders for</h4>
|
|
</div>
|
|
<span>Displays inherit the channel they are assigned to.</span>
|
|
</div>
|
|
<div className="signage-channel-list">
|
|
{group.channels.map((channel) => {
|
|
const assigned = readyViewers.filter((viewer) => viewer.channelId === channel.id);
|
|
return (
|
|
<article className="signage-channel" key={channel.id}>
|
|
<header className="signage-channel__header">
|
|
<div>
|
|
<h5>{channel.name}</h5>
|
|
<div className="signage-channel__meta">
|
|
{channel.isDefault ? <Badge tone="success">Default channel</Badge> : undefined}
|
|
<span>Audience key</span>
|
|
<code>{channel.key}</code>
|
|
<Badge tone="neutral">
|
|
{`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
{canManage && !channel.isDefault ? (
|
|
<Button
|
|
disabled={edit.pending}
|
|
onClick={() => edit.run(() => api.send("DELETE", `channels/${channel.id}`))}
|
|
variant="danger"
|
|
>
|
|
Remove channel
|
|
</Button>
|
|
) : undefined}
|
|
</header>
|
|
<div className="signage-channel__displays">
|
|
<span className="signage-hierarchy-label">Displays</span>
|
|
{assigned.length === 0 ? (
|
|
<p className="signage-channel__empty">No displays assigned.</p>
|
|
) : (
|
|
assigned.map((viewer) => (
|
|
<DisplayRow
|
|
canManage={canManage}
|
|
groups={value.groups}
|
|
key={viewer.id}
|
|
nowMs={nowMs}
|
|
onChannelChange={(channelId) => updateViewer(viewer.id, { channelId })}
|
|
onNameChange={(name) => updateViewer(viewer.id, { name })}
|
|
viewer={viewer}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{canManage ? (
|
|
<div className="signage-channel-form" data-testid="signage-add-channel">
|
|
<div className="signage-channel-form__heading">
|
|
<h5>Add an audience channel</h5>
|
|
<span>The default channel always remains available.</span>
|
|
</div>
|
|
<Field hint="Shown to administrators and when assigning displays." label="Channel name">
|
|
{(control) => (
|
|
<TextInput
|
|
{...control}
|
|
maxLength={120}
|
|
onChange={(event) =>
|
|
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 : ""}
|
|
/>
|
|
)}
|
|
</Field>
|
|
<Field
|
|
hint="Advanced: lowercase ID sent to content providers, e.g. online."
|
|
label="Audience key"
|
|
>
|
|
{(control) => (
|
|
<TextInput
|
|
{...control}
|
|
maxLength={64}
|
|
onChange={(event) =>
|
|
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 : ""}
|
|
/>
|
|
)}
|
|
</Field>
|
|
<Button
|
|
disabled={
|
|
edit.pending ||
|
|
channelDraft.groupId !== group.id ||
|
|
!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
|
|
channelDraft.key.trim() === "default" ||
|
|
channelDraft.name.trim() === ""
|
|
}
|
|
onClick={() =>
|
|
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
|
|
</Button>
|
|
</div>
|
|
) : undefined}
|
|
</section>
|
|
</article>
|
|
))}
|
|
|
|
{unassigned.length === 0 ? undefined : (
|
|
<section className="signage-unassigned">
|
|
<span className="signage-hierarchy-label">Needs assignment</span>
|
|
{unassigned.map((viewer) => (
|
|
<DisplayRow
|
|
canManage={canManage}
|
|
groups={value.groups}
|
|
key={viewer.id}
|
|
nowMs={nowMs}
|
|
onChannelChange={(channelId) => updateViewer(viewer.id, { channelId })}
|
|
onNameChange={(name) => updateViewer(viewer.id, { name })}
|
|
viewer={viewer}
|
|
/>
|
|
))}
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
{ label: "Loading channel groups…", onRetry: groups.reload },
|
|
)}
|
|
{renderLoadable(viewers.state, () => null, {
|
|
label: "Loading displays…",
|
|
onRetry: viewers.reload,
|
|
})}
|
|
<FailureNote failure={edit.failure} />
|
|
<FailureNote failure={viewerEdit.failure} />
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|