Merge pull request 'feat: align Signage with platform display and design' (#3) from fix/signage-playback-and-displays into main

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
The_River
2026-10-01 21:36:31 +00:00
9 changed files with 3043 additions and 2386 deletions
+165 -135
View File
@@ -1,5 +1,5 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui";
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
import { type ReactElement, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js";
@@ -23,144 +23,174 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
*/
export function SignageDashboard({
context,
copyText,
displaySetupUrl,
context,
copyText,
}: {
readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string;
readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>;
}): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
// A hidden screen is a usability choice; the server is what actually refuses, and making a
// request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view");
const settings = useResource(
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
[mayView],
);
const groups = useResource(
() =>
mayView
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
const api = useMemo(() => createSignageApi(context.api), [context.api]);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
// A hidden screen is a usability choice; the server is what actually refuses, and making a
// request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view");
const settings = useResource(
() =>
mayView
? api.get<SettingsResponse>("settings")
: Promise.resolve(undefined),
[mayView],
);
const groups = useResource(
() =>
mayView
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage");
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
const mayControl = context.permissions.has("signage.playback.control");
const mayManageDisplays = context.permissions.has("signage.displays.manage");
const mayManageAssets = context.permissions.has("signage.assets.manage");
const mayManageTimelines = context.permissions.has(
"signage.timelines.manage",
);
const mayControl = context.permissions.has("signage.playback.control");
const mayManageDisplays = context.permissions.has("signage.displays.manage");
if (!mayView) {
return (
<EmptyState
description="Ask an administrator for the signage.view permission."
title="No access to Signage"
/>
);
}
if (!mayView) {
return (
<EmptyState
description="Ask an administrator for the signage.view permission."
title="No access to Signage"
/>
);
}
const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
const selectedGroup =
groups.state.status === "ready"
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId)
: undefined;
const selectedGroupId =
groupId ??
(groups.state.status === "ready"
? groups.state.value?.groups[0]?.id
: undefined);
const selectedGroup =
groups.state.status === "ready"
? groups.state.value?.groups.find(
(candidate) => candidate.id === selectedGroupId,
)
: undefined;
return (
<>
<EkklesiaUiStyles />
<DesktopOnly>
{renderLoadable(
settings.state,
(loaded) =>
loaded === undefined ? undefined : (
<Tabs
label="Signage sections"
onSelect={setTab}
selectedId={tab}
tabs={[
{
content: (
<PlaybackPanel
api={api}
canControl={mayControl}
context={context}
groupId={selectedGroupId}
onGroupChange={setGroupId}
settings={loaded}
/>
),
id: "playback",
label: "Playback",
},
{
content: (
<TimelinesPanel
{...(selectedGroup
? {
activeGroupName: selectedGroup.name,
activeTimelineId: selectedGroup.playback.activeTimelineId,
}
: {})}
api={api}
canManage={mayManageTimelines}
onActivate={
mayControl && selectedGroupId !== undefined
? async (timelineId) => {
await api.send("POST", `playback/${selectedGroupId}/activate`, {
timelineId,
when: "now",
});
groups.reload();
setTab("playback");
}
: undefined
}
settings={loaded}
/>
),
id: "timelines",
label: "Timelines",
},
{
content: (
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
),
id: "images",
label: "Images",
},
{
content: (
<DisplaysPanel
api={api}
canManage={mayManageDisplays}
copyText={copyText}
displaySetupUrl={displaySetupUrl}
onGroupsChanged={() => {
setGroupId(undefined);
groups.reload();
}}
/>
),
id: "displays",
label: "Displays",
},
{
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
id: "providers",
label: "Providers",
},
]}
/>
),
{ label: "Loading Signage…", onRetry: settings.reload },
)}
</DesktopOnly>
</>
);
return (
<>
<EkklesiaUiStyles />
<DesktopOnly>
<PageHeader
description="Plan visual content, operate playback, and keep every screen connected."
eyebrow="Media"
title="Signage"
/>
{renderLoadable(
settings.state,
(loaded) =>
loaded === undefined ? undefined : (
<div className="signage-workspace">
<Tabs
label="Signage sections"
onSelect={setTab}
selectedId={tab}
tabs={[
{
content: (
<PlaybackPanel
api={api}
canControl={mayControl}
context={context}
groupId={selectedGroupId}
onGroupChange={setGroupId}
settings={loaded}
/>
),
id: "playback",
label: "Playback",
},
{
content: (
<TimelinesPanel
{...(selectedGroup
? {
activeGroupName: selectedGroup.name,
activeTimelineId:
selectedGroup.playback.activeTimelineId,
}
: {})}
api={api}
canManage={mayManageTimelines}
onActivate={
mayControl && selectedGroupId !== undefined
? async (timelineId) => {
await api.send(
"POST",
`playback/${selectedGroupId}/activate`,
{
timelineId,
when: "now",
},
);
groups.reload();
setTab("playback");
}
: undefined
}
settings={loaded}
/>
),
id: "timelines",
label: "Timelines",
},
{
content: (
<ImagesPanel
api={api}
canManage={mayManageAssets}
settings={loaded}
/>
),
id: "images",
label: "Images",
},
{
content: (
<DisplaysPanel
api={api}
canManage={mayManageDisplays}
copyText={copyText}
displaySetupUrl={context.display?.url}
onGroupsChanged={() => {
setGroupId(undefined);
groups.reload();
}}
/>
),
id: "displays",
label: "Displays",
},
{
content: (
<ProvidersPanel
api={api}
context={context}
settings={loaded}
/>
),
id: "providers",
label: "Providers",
},
]}
/>
</div>
),
{ label: "Loading Signage…", onRetry: settings.reload },
)}
</DesktopOnly>
</>
);
}
+20 -20
View File
@@ -14,24 +14,24 @@ import ownStyles from "./styles.css";
* rather than bundled (ADR 0039).
*/
export default defineModuleUi({
id: "signage",
version: "0.1.1",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) {
const root = createRoot(container);
const displaySetupUrl = new URL("/display/signage", globalThis.location.origin).href;
root.render(
<SignageDashboard
context={context}
copyText={async (value) => {
if (globalThis.navigator.clipboard === undefined) {
throw new Error("Clipboard access is not available in this browser.");
}
await globalThis.navigator.clipboard.writeText(value);
}}
displaySetupUrl={displaySetupUrl}
/>,
);
return () => root.unmount();
},
id: "signage",
version: "0.1.1",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) {
const root = createRoot(container);
root.render(
<SignageDashboard
context={context}
copyText={async (value) => {
if (globalThis.navigator.clipboard === undefined) {
throw new Error(
"Clipboard access is not available in this browser.",
);
}
await globalThis.navigator.clipboard.writeText(value);
}}
/>,
);
return () => root.unmount();
},
});
+536 -403
View File
@@ -1,78 +1,102 @@
import { Badge, Button, Field, Panel, Select, TextInput } from "@ekklesia/ui";
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";
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,
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;
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>
);
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>
);
}
/**
@@ -80,350 +104,459 @@ function DisplayRow({
* time: one group clock, one audience variant per channel, then the screens assigned to it.
*/
export function DisplaysPanel({
api,
canManage,
copyText,
displaySetupUrl,
onGroupsChanged,
api,
canManage,
copyText,
displaySetupUrl,
onGroupsChanged,
}: {
readonly api: SignageApi;
readonly canManage: boolean;
readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string;
readonly onGroupsChanged?: () => void;
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"), []);
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 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));
};
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>
return (
<div className="signage-columns">
<Panel
actions={
displaySetupUrl === undefined ? undefined : (
<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>
{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 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}
<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>
<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>
))}
{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>
);
{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>
);
}
+330 -262
View File
@@ -1,16 +1,29 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
import {
ActionBar,
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
} 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,
PlaybackDetailView,
SettingsResponse,
TimelineSummary,
ChannelGroupView,
PlaybackDetailView,
SettingsResponse,
TimelineSummary,
} from "../types.js";
import { PreviewPanel } from "./PreviewPanel.js";
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
import {
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
/**
* Live transport for one channel group.
@@ -20,270 +33,325 @@ import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./sh
* button, and neither can produce a state the other did not ask for.
*/
export function PlaybackPanel({
api,
context,
settings,
canControl,
groupId,
onGroupChange,
api,
context,
settings,
canControl,
groupId,
onGroupChange,
}: {
readonly api: SignageApi;
readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse;
readonly canControl: boolean;
readonly groupId: string | undefined;
readonly onGroupChange: (groupId: string) => void;
readonly api: SignageApi;
readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse;
readonly canControl: boolean;
readonly groupId: string | undefined;
readonly onGroupChange: (groupId: string) => void;
}): ReactElement {
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
);
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
);
const detail = useResource(
() =>
groupId === undefined
? Promise.resolve(undefined)
: api.get<PlaybackDetailView>(`playback/${groupId}`),
[groupId],
);
const command = useAction(() => {
detail.reload();
groups.reload();
});
const [activateId, setActivateId] = useState("");
const previewChannelId =
groups.state.status === "ready"
? (groups.state.value.groups
.find((group) => group.id === groupId)
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
: undefined;
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
useTick(1000);
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
);
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
);
const detail = useResource(
() =>
groupId === undefined
? Promise.resolve(undefined)
: api.get<PlaybackDetailView>(`playback/${groupId}`),
[groupId],
);
const command = useAction(() => {
detail.reload();
groups.reload();
});
const [activateId, setActivateId] = useState("");
const previewChannelId =
groups.state.status === "ready"
? (groups.state.value.groups
.find((group) => group.id === groupId)
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
: undefined;
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
useTick(1000);
function run(path: string, body?: unknown): void {
if (groupId === undefined) {
return;
}
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
}
function run(path: string, body?: unknown): void {
if (groupId === undefined) {
return;
}
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
}
function timelineLabel(timelineId: string): string {
if (timelines.state.status !== "ready") {
return timelineId;
}
return (
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
timelineId
);
}
function timelineLabel(timelineId: string): string {
if (timelines.state.status !== "ready") {
return timelineId;
}
return (
timelines.state.value.timelines.find(
(timeline) => timeline.id === timelineId,
)?.name ?? timelineId
);
}
return (
<div className="signage-columns">
<Panel
actions={renderLoadable(
groups.state,
(value) => (
<Field label="Channel group">
{(control) => (
<Select
{...control}
onChange={(event) => onGroupChange(event.target.value)}
value={groupId ?? ""}
>
{value.groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
)}
</Field>
),
{ label: "Loading groups…" },
)}
description="One clock for every channel in the group."
title="Playback"
>
{groupId === undefined ? (
<EmptyState title="No channel group selected" />
) : (
renderLoadable(
detail.state,
(value) =>
value === undefined ? (
<EmptyState title="No channel group selected" />
) : (
<div className="signage-playback" data-testid="signage-playback">
<div className="signage-playback__status">
{value.snapshot.activeTimelineId === null ? undefined : (
<Badge tone="success">
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
</Badge>
)}
<Badge
tone={
value.snapshot.transport === "playing"
? "success"
: value.snapshot.transport === "idle"
? "warning"
: "info"
}
>
{value.snapshot.transport}
</Badge>
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
{value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge>
) : undefined}
{value.current?.withdrawn === true ? (
<Badge tone="warning">Withdrawn</Badge>
) : undefined}
{value.snapshot.pendingTimelineId === null ? undefined : (
<Badge tone="info">Activation queued</Badge>
)}
</div>
return (
<div className="signage-columns signage-playback-layout">
<Panel
actions={renderLoadable(
groups.state,
(value) => (
<Field label="Channel group">
{(control) => (
<Select
{...control}
onChange={(event) => onGroupChange(event.target.value)}
value={groupId ?? ""}
>
{value.groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
)}
</Field>
),
{ label: "Loading groups…" },
)}
description="One clock for every channel in the group."
title="Playback"
>
{groupId === undefined ? (
<EmptyState title="No channel group selected" />
) : (
renderLoadable(
detail.state,
(value) =>
value === undefined ? (
<EmptyState title="No channel group selected" />
) : (
<div
className="signage-playback"
data-testid="signage-playback"
>
<div className="signage-playback__status">
{value.snapshot.activeTimelineId === null ? undefined : (
<Badge tone="success">
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
</Badge>
)}
<Badge
tone={
value.snapshot.transport === "playing"
? "success"
: value.snapshot.transport === "idle"
? "warning"
: "info"
}
>
{value.snapshot.transport}
</Badge>
{value.snapshot.blackout ? (
<Badge tone="danger">Blackout</Badge>
) : undefined}
{value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge>
) : undefined}
{value.current?.withdrawn === true ? (
<Badge tone="warning">Withdrawn</Badge>
) : undefined}
{value.snapshot.pendingTimelineId === null ? undefined : (
<Badge tone="info">Activation queued</Badge>
)}
</div>
{value.current === null ? (
<EmptyState
description="Nothing is currently resolvable. A display shows its safe slate."
title="Nothing on screen"
/>
) : (
<dl className="signage-playback__current" data-testid="signage-current">
<dt>Provider</dt>
<dd>{value.current.providerId}</dd>
<dt>Item</dt>
<dd>{value.current.itemId}</dd>
<dt>Renderer</dt>
<dd>{value.current.rendererContributionId ?? "—"}</dd>
<dt>Duration</dt>
<dd>{formatDuration(value.current.durationMs)}</dd>
<dt>Remaining</dt>
<dd data-testid="signage-remaining">
{value.snapshot.remainingMs === null
? "—"
: formatDuration(value.snapshot.remainingMs)}
</dd>
<dt>Started</dt>
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
</dl>
)}
{value.current === null ? (
<EmptyState
description="Nothing is currently resolvable. A display shows its safe slate."
title="Nothing on screen"
/>
) : (
<section className="signage-playback__now">
<h3>Now showing</h3>
<dl
className="signage-playback__current"
data-testid="signage-current"
>
<dt>Provider</dt>
<dd>{value.current.providerId}</dd>
<dt>Item</dt>
<dd>{value.current.itemId}</dd>
<dt>Renderer</dt>
<dd>{value.current.rendererContributionId ?? "—"}</dd>
<dt>Duration</dt>
<dd>{formatDuration(value.current.durationMs)}</dd>
<dt>Remaining</dt>
<dd data-testid="signage-remaining">
{value.snapshot.remainingMs === null
? "—"
: formatDuration(value.snapshot.remainingMs)}
</dd>
<dt>Started</dt>
<dd>
{formatInstant(
value.current.startedAtMs,
settings.timezone,
)}
</dd>
</dl>
</section>
)}
{canControl ? (
<div className="signage-playback__controls" data-testid="signage-controls">
<Button disabled={command.pending} onClick={() => run("previous")}>
Previous
</Button>
{value.snapshot.transport === "paused" ? (
<Button
disabled={command.pending}
onClick={() => run("resume")}
variant="primary"
>
Resume
</Button>
) : (
<Button disabled={command.pending} onClick={() => run("pause")}>
Pause
</Button>
)}
<Button disabled={command.pending} onClick={() => run("next")}>
Next
</Button>
<Button
disabled={command.pending}
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
variant={value.snapshot.blackout ? "primary" : "secondary"}
>
{value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button>
{value.snapshot.staticAssetId === null ? undefined : (
<Button
disabled={command.pending}
onClick={() => run("static", { assetId: null })}
>
Release hold
</Button>
)}
<Button disabled={command.pending} onClick={() => run("reload")}>
Reload content
</Button>
</div>
) : undefined}
{canControl ? (
<ActionBar
className="signage-playback__controls"
data-testid="signage-controls"
label="Playback controls"
>
<Button
disabled={command.pending}
onClick={() => run("previous")}
>
Previous
</Button>
{value.snapshot.transport === "paused" ? (
<Button
disabled={command.pending}
onClick={() => run("resume")}
variant="primary"
>
Resume
</Button>
) : (
<Button
disabled={command.pending}
onClick={() => run("pause")}
>
Pause
</Button>
)}
<Button
disabled={command.pending}
onClick={() => run("next")}
>
Next
</Button>
<Button
disabled={command.pending}
onClick={() =>
run("blackout", { on: !value.snapshot.blackout })
}
variant={
value.snapshot.blackout ? "primary" : "secondary"
}
>
{value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button>
{value.snapshot.staticAssetId === null ? undefined : (
<Button
disabled={command.pending}
onClick={() => run("static", { assetId: null })}
>
Release hold
</Button>
)}
<Button
disabled={command.pending}
onClick={() => run("reload")}
>
Reload content
</Button>
</ActionBar>
) : undefined}
{canControl ? (
<div className="signage-playback__activate">
{renderLoadable(
timelines.state,
(loaded) => (
<>
<Field label="Activate timeline">
{(control) => (
<Select
{...control}
onChange={(event) => setActivateId(event.target.value)}
value={activateId}
>
<option value="">Choose a timeline…</option>
{loaded.timelines.map((timeline) => (
<option key={timeline.id} value={timeline.id}>
{timeline.name}
</option>
))}
</Select>
)}
</Field>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "now" })
}
variant="primary"
>
Now
</Button>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "next-boundary" })
}
>
At next slot
</Button>
</>
),
{ label: "Loading timelines…" },
)}
</div>
) : undefined}
{canControl ? (
<div className="signage-playback__activate">
{renderLoadable(
timelines.state,
(loaded) => (
<>
<Field label="Activate timeline">
{(control) => (
<Select
{...control}
onChange={(event) =>
setActivateId(event.target.value)
}
value={activateId}
>
<option value="">Choose a timeline…</option>
{loaded.timelines.map((timeline) => (
<option
key={timeline.id}
value={timeline.id}
>
{timeline.name}
</option>
))}
</Select>
)}
</Field>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", {
timelineId: activateId,
when: "now",
})
}
variant="primary"
>
Now
</Button>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", {
timelineId: activateId,
when: "next-boundary",
})
}
>
At next slot
</Button>
</>
),
{ label: "Loading timelines…" },
)}
</div>
) : undefined}
{value.history.length === 0 ? undefined : (
<div className="signage-playback__history">
<h4 className="signage-editor__subtitle">Recently played</h4>
<ul className="ek-list">
{value.history.slice(0, 8).map((entry) => (
<li className="ek-list__item signage-row" key={entry.id}>
<span className="signage-row__name">{entry.itemId}</span>
<span className="signage-playback__history-meta">
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
</span>
</li>
))}
</ul>
</div>
)}
</div>
),
{ label: "Loading playback…", onRetry: detail.reload },
)
)}
<FailureNote failure={command.failure} />
</Panel>
{value.history.length === 0 ? undefined : (
<div className="signage-playback__history">
<h4 className="signage-editor__subtitle">
Recently played
</h4>
<ul className="ek-list">
{value.history.slice(0, 8).map((entry) => (
<li
className="ek-list__item signage-row"
key={entry.id}
>
<span className="signage-row__name">
{entry.itemId}
</span>
<span className="signage-playback__history-meta">
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
</span>
</li>
))}
</ul>
</div>
)}
</div>
),
{ label: "Loading playback…", onRetry: detail.reload },
)
)}
<FailureNote failure={command.failure} />
</Panel>
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
</div>
);
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
</div>
);
}
+102 -87
View File
@@ -1,13 +1,13 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import {
Badge,
EmptyState,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Badge,
EmptyState,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@ekklesia/ui";
import type { ReactElement } from "react";
import type { SignageApi } from "../api.js";
@@ -31,86 +31,101 @@ const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
* exactly that, which is the difference between "this is broken" and "this needs an update".
*/
export function ProvidersPanel({
api,
context,
settings,
api,
context,
settings,
}: {
readonly api: SignageApi;
readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse;
readonly api: SignageApi;
readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse;
}): ReactElement {
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[],
);
const published = new Set(
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
);
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[],
);
const published = new Set(
context.contributions
.list(SLIDE_RENDERER_CAPABILITY)
.map((contribution) => contribution.id),
);
return (
<Panel
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
title="Content providers"
>
{renderLoadable(
providers.state,
(value) =>
value.providers.length === 0 ? (
<EmptyState
description="Signage works on its own with native images. Install a content module to add more."
title="No content providers registered"
/>
) : (
<Table label="Provider health">
<TableHead>
<TableRow>
<TableCell header>Provider</TableCell>
<TableCell header>Contract</TableCell>
<TableCell header>Renderer</TableCell>
<TableCell header>Health</TableCell>
<TableCell header>Last success</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.providers.map((provider) => {
const rendererPublished = published.has(provider.renderer.contributionId);
const isNative = provider.renderer.contributionId === "signage.native-image";
return (
<TableRow key={provider.providerId}>
<TableCell>
<span className="signage-provider__id">{provider.providerId}</span>
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
</TableCell>
<TableCell>{provider.contractVersion}</TableCell>
<TableCell>
{isNative ? (
<Badge tone="success">Signage renders this</Badge>
) : rendererPublished ? (
<Badge tone="success">{provider.renderer.contributionId}</Badge>
) : (
<Badge tone="warning">Renderer not published</Badge>
)}
</TableCell>
<TableCell>
{provider.consecutiveFailures === 0 ? (
<Badge tone="success">Healthy</Badge>
) : (
<Badge tone="danger">
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
</Badge>
)}
</TableCell>
<TableCell>
{formatInstant(provider.lastSuccessAt, settings.timezone)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
),
{ label: "Loading providers…", onRetry: providers.reload },
)}
</Panel>
);
return (
<Panel
actions={
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
}
description="Running content sources available to timelines."
title="Content providers"
>
{renderLoadable(
providers.state,
(value) =>
value.providers.length === 0 ? (
<EmptyState
description="Signage works on its own with native images. Install a content module to add more."
title="No content providers registered"
/>
) : (
<Table label="Provider health">
<TableHead>
<TableRow>
<TableCell header>Provider</TableCell>
<TableCell header>Contract</TableCell>
<TableCell header>Renderer</TableCell>
<TableCell header>Health</TableCell>
<TableCell header>Last success</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.providers.map((provider) => {
const rendererPublished = published.has(
provider.renderer.contributionId,
);
const isNative =
provider.renderer.contributionId === "signage.native-image";
return (
<TableRow key={provider.providerId}>
<TableCell>
<span className="signage-provider__id">
{provider.providerId}
</span>
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
</TableCell>
<TableCell>{provider.contractVersion}</TableCell>
<TableCell>
{isNative ? (
<Badge tone="info">Built in</Badge>
) : rendererPublished ? (
<Badge tone="success">
{provider.renderer.contributionId}
</Badge>
) : (
<Badge tone="warning">Renderer not published</Badge>
)}
</TableCell>
<TableCell>
{provider.consecutiveFailures === 0 ? (
<Badge tone="success">Healthy</Badge>
) : (
<Badge tone="danger">
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
</Badge>
)}
</TableCell>
<TableCell>
{formatInstant(
provider.lastSuccessAt,
settings.timezone,
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
),
{ label: "Loading providers…", onRetry: providers.reload },
)}
</Panel>
);
}
File diff suppressed because it is too large Load Diff
+441 -379
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+113 -89
View File
@@ -1,10 +1,10 @@
import type {
ModuleContributionDescriptor,
ModuleUiApiClient,
ModuleUiContributions,
ModuleUiHostContext,
MountedModuleContribution,
MountModuleContributionOptions,
ModuleContributionDescriptor,
ModuleUiApiClient,
ModuleUiContributions,
ModuleUiHostContext,
MountedModuleContribution,
MountModuleContributionOptions,
} from "@ekklesia/module-ui-sdk";
/**
@@ -15,108 +15,132 @@ import type {
* simply never match.
*/
export interface FakeHostRoute {
readonly method: string;
readonly path: string | RegExp;
readonly respond: (request: {
readonly path: string;
readonly init: RequestInit | undefined;
}) => Response | Promise<Response>;
readonly method: string;
readonly path: string | RegExp;
readonly respond: (request: {
readonly path: string;
readonly init: RequestInit | undefined;
}) => Response | Promise<Response>;
}
export interface FakeHost {
readonly context: ModuleUiHostContext;
readonly requests: { method: string; path: string }[];
/** Contributions this host published against Signage's renderer capability. */
readonly mounted: { contributionId: string; input: unknown }[];
readonly context: ModuleUiHostContext;
readonly requests: { method: string; path: string }[];
/** Contributions this host published against Signage's renderer capability. */
readonly mounted: { contributionId: string; input: unknown }[];
}
export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
export function errorResponse(code: string, message: string, status = 400): Response {
return jsonResponse({ code, message }, status);
export function errorResponse(
code: string,
message: string,
status = 400,
): Response {
return jsonResponse({ code, message }, status);
}
export interface FakeHostOptions {
readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[];
readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: (
options: MountModuleContributionOptions,
) => Promise<MountedModuleContribution> | MountedModuleContribution;
readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[];
readonly displayUrl?: string;
readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: (
options: MountModuleContributionOptions,
) => Promise<MountedModuleContribution> | MountedModuleContribution;
}
export function createFakeHost(options: FakeHostOptions): FakeHost {
const requests: { method: string; path: string }[] = [];
const mounted: { contributionId: string; input: unknown }[] = [];
const requests: { method: string; path: string }[] = [];
const mounted: { contributionId: string; input: unknown }[] = [];
const api: ModuleUiApiClient = {
async request(path, init) {
const method = (init?.method ?? "GET").toUpperCase();
const normalized = path.replace(/^\//u, "");
requests.push({ method, path: normalized });
for (const route of options.routes) {
const matches =
typeof route.path === "string"
? route.path === normalized.split("?")[0]
: route.path.test(normalized);
if (route.method === method && matches) {
return route.respond({ init, path: normalized });
}
}
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
},
};
const api: ModuleUiApiClient = {
async request(path, init) {
const method = (init?.method ?? "GET").toUpperCase();
const normalized = path.replace(/^\//u, "");
requests.push({ method, path: normalized });
for (const route of options.routes) {
const matches =
typeof route.path === "string"
? route.path === normalized.split("?")[0]
: route.path.test(normalized);
if (route.method === method && matches) {
return route.respond({ init, path: normalized });
}
}
return errorResponse(
"SIGNAGE_NOT_FOUND",
"The requested resource was not found.",
404,
);
},
};
const contributions: ModuleUiContributions = {
list: (capabilityId) =>
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
async mount(mountOptions) {
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input });
if (options.mount) {
return options.mount(mountOptions);
}
return {
contributionId: mountOptions.contributionId,
mounted: true,
unmount: () => Promise.resolve(),
update: () => Promise.resolve(),
version: "1.0.0",
};
},
};
const contributions: ModuleUiContributions = {
list: (capabilityId) =>
capabilityId === "signage.slide-renderer"
? (options.contributions ?? [])
: [],
async mount(mountOptions) {
mounted.push({
contributionId: mountOptions.contributionId,
input: mountOptions.input,
});
if (options.mount) {
return options.mount(mountOptions);
}
return {
contributionId: mountOptions.contributionId,
mounted: true,
unmount: () => Promise.resolve(),
update: () => Promise.resolve(),
version: "1.0.0",
};
},
};
return {
context: {
api,
contributions,
moduleId: "signage",
permissions: { has: (permission) => options.permissions.includes(permission) },
principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) },
},
mounted,
requests,
};
return {
context: {
api,
contributions,
display: {
url: options.displayUrl ?? "https://api.example.test/display/signage",
},
moduleId: "signage",
permissions: {
has: (permission) => options.permissions.includes(permission),
},
principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) },
},
mounted,
requests,
};
}
/** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000,
moduleVersion: "0.1.1",
nativeImageDurationMs: 7000,
nativePresentation: {
backgroundColor: "#000000",
backgroundDarkened: false,
backgroundMode: "blurred",
blurPixels: 20,
},
rendererInputVersion: "1.0.0",
timezone: "America/Chicago",
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
crossfadeMs: 1000,
moduleVersion: "0.1.1",
nativeImageDurationMs: 7000,
nativePresentation: {
backgroundColor: "#000000",
backgroundDarkened: false,
backgroundMode: "blurred",
blurPixels: 20,
},
rendererInputVersion: "1.0.0",
timezone: "America/Chicago",
uploads: {
batchMaxFiles: 20,
concurrency: 3,
maxBytes: 26_214_400,
maxPixels: 40_000_000,
},
});