Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 <[email protected]>
530 lines
22 KiB
TypeScript
530 lines
22 KiB
TypeScript
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 { ConfirmButton, FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
|
|
|
|
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
|
|
/**
|
|
* The key content providers use to pick an audience's version of a slide, derived from the name an
|
|
* operator typed. It is an implementation detail: staff name an audience ("Online"), and only an
|
|
* operator who knows a provider expects a particular key needs the Advanced override.
|
|
*/
|
|
export function audienceKeyFor(name: string, taken: readonly string[]): string {
|
|
const base =
|
|
name
|
|
.normalize("NFKD")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/gu, "-")
|
|
.replace(/^-+|-+$/gu, "")
|
|
.slice(0, 48)
|
|
.replace(/-+$/u, "") || "audience";
|
|
const start = /^[a-z]/u.test(base) ? base : `audience-${base}`;
|
|
const reserved = new Set([...taken, "default"]);
|
|
let candidate = start;
|
|
for (let suffix = 2; reserved.has(candidate); suffix += 1) {
|
|
candidate = `${start}-${suffix}`;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function DisplayRow({
|
|
canManage,
|
|
groups,
|
|
nowMs,
|
|
onChannelChange,
|
|
onNameChange,
|
|
onRemove,
|
|
viewer,
|
|
}: {
|
|
readonly canManage: boolean;
|
|
readonly groups: readonly ChannelGroupView[];
|
|
readonly nowMs: number;
|
|
readonly onChannelChange: (channelId: string) => void;
|
|
readonly onNameChange: (name: string) => void;
|
|
readonly onRemove: () => 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} />
|
|
{viewer.deviceId === null ? (
|
|
// Opened by someone signed in — a test tab on a laptop, not a paired screen.
|
|
<Badge tone="neutral">Signed-in browser</Badge>
|
|
) : undefined}
|
|
</div>
|
|
<div className="signage-display-row__assignment">
|
|
{canManage ? (
|
|
<Field label="Audience">
|
|
{(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>
|
|
)}
|
|
{canManage && !viewer.online ? (
|
|
// Only an offline entry: a screen that is still connected would simply reappear.
|
|
<ConfirmButton
|
|
confirmLabel="Remove from the list"
|
|
label="Remove"
|
|
onConfirm={onRemove}
|
|
testId={`signage-display-remove-${viewer.id}`}
|
|
/>
|
|
) : undefined}
|
|
</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 | undefined;
|
|
readonly onGroupsChanged?: () => void;
|
|
}): ReactElement {
|
|
const groups = useResource(
|
|
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
|
[],
|
|
"channel-groups",
|
|
);
|
|
const viewers = useResource(
|
|
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
|
[],
|
|
"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));
|
|
};
|
|
const removeViewer = (viewerId: string): void => {
|
|
viewerEdit.run(() => api.send("DELETE", `viewers/${viewerId}`));
|
|
};
|
|
|
|
return (
|
|
<div className="signage-columns">
|
|
<Panel
|
|
actions={
|
|
displaySetupUrl === undefined ? undefined : (
|
|
<div className="signage-pairing-actions">
|
|
<Button
|
|
onClick={() => {
|
|
setCopyStatus("idle");
|
|
void copyText(displaySetupUrl).then(
|
|
() => setCopyStatus("copied"),
|
|
() => setCopyStatus("failed"),
|
|
);
|
|
}}
|
|
variant="primary"
|
|
>
|
|
Copy setup address
|
|
</Button>
|
|
<a
|
|
className="ek-button ek-focusable ek-button--secondary"
|
|
href={displaySetupUrl}
|
|
rel="noopener noreferrer"
|
|
target="_blank"
|
|
>
|
|
Preview here
|
|
</a>
|
|
</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>
|
|
On the screen that will show signage, open the display setup address. Copy it from
|
|
here; opening it on this computer only previews the display.
|
|
</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 group below.</span>
|
|
</li>
|
|
</ol>
|
|
{displaySetupUrl === undefined ? (
|
|
<InlineNotice tone="warning" title="Display setup unavailable">
|
|
This host did not provide a display setup address.
|
|
</InlineNotice>
|
|
) : undefined}
|
|
<span className="signage-copy-status" aria-live="polite">
|
|
{copyStatus === "copied"
|
|
? "Display setup address copied."
|
|
: copyStatus === "failed"
|
|
? "Could not copy the address. Use Preview here and copy it from the address bar."
|
|
: ""}
|
|
</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="Displays in one group play together, on the same slide at the same moment. Use another group for screens that should run their own loop."
|
|
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">Audiences</span>
|
|
<h4>Who is watching these displays</h4>
|
|
</div>
|
|
<span>
|
|
Every display in this group shows the same slide at the same moment. Add
|
|
an audience only when some screens should get a different version of it,
|
|
such as wording for people watching online. Images look the same for every
|
|
audience.
|
|
</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 audience</Badge>
|
|
) : undefined}
|
|
<Badge tone="neutral">
|
|
{`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
{canManage && !channel.isDefault ? (
|
|
<ConfirmButton
|
|
confirmLabel={
|
|
assigned.length === 0
|
|
? `Remove ${channel.name}`
|
|
: `Remove ${channel.name} and move its displays to the default audience`
|
|
}
|
|
disabled={edit.pending}
|
|
label="Remove audience"
|
|
onConfirm={() =>
|
|
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
|
|
}
|
|
testId={`signage-channel-remove-${channel.id}`}
|
|
/>
|
|
) : 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 })}
|
|
onRemove={() => removeViewer(viewer.id)}
|
|
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</h5>
|
|
<span>The default audience always remains.</span>
|
|
</div>
|
|
<Field
|
|
hint="Shown when you assign displays, for example Online."
|
|
label="Audience 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"
|
|
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
|
/>
|
|
)}
|
|
</Field>
|
|
<details className="signage-advanced">
|
|
<summary>Advanced</summary>
|
|
<Field
|
|
hint={`Only needed when a content module expects a particular key. Leave empty to use ${audienceKeyFor(
|
|
channelDraft.groupId === group.id ? channelDraft.name : "",
|
|
group.channels.map((channel) => channel.key),
|
|
)}.`}
|
|
label="Content 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]+)*"
|
|
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
|
/>
|
|
)}
|
|
</Field>
|
|
</details>
|
|
<Button
|
|
disabled={
|
|
edit.pending ||
|
|
channelDraft.groupId !== group.id ||
|
|
channelDraft.name.trim() === "" ||
|
|
(channelDraft.key.trim() !== "" &&
|
|
(!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
|
|
channelDraft.key.trim() === "default"))
|
|
}
|
|
onClick={() =>
|
|
edit.run(async () => {
|
|
await api.send("POST", `channel-groups/${group.id}/channels`, {
|
|
key:
|
|
channelDraft.key.trim() ||
|
|
audienceKeyFor(
|
|
channelDraft.name,
|
|
group.channels.map((channel) => channel.key),
|
|
),
|
|
name: channelDraft.name.trim(),
|
|
});
|
|
setChannelDraft({
|
|
groupId: "",
|
|
key: "",
|
|
name: "",
|
|
});
|
|
})
|
|
}
|
|
variant="primary"
|
|
>
|
|
Add audience
|
|
</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 })}
|
|
onRemove={() => removeViewer(viewer.id)}
|
|
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>
|
|
);
|
|
}
|