feat: align Signage with platform display and design #3

Merged
Zayin_Admin merged 1 commits from fix/signage-playback-and-displays into main 2026-10-01 21:36:32 +00:00
9 changed files with 3043 additions and 2386 deletions
Showing only changes of commit 1bf89a3942 - Show all commits
+43 -13
View File
@@ -1,5 +1,5 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; 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 { type ReactElement, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js"; import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js"; import { createSignageApi } from "./api.js";
@@ -25,11 +25,9 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
export function SignageDashboard({ export function SignageDashboard({
context, context,
copyText, copyText,
displaySetupUrl,
}: { }: {
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>; readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string;
}): ReactElement { }): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]); 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. // Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
@@ -37,7 +35,10 @@ export function SignageDashboard({
// request it will certainly refuse is noise in a log rather than security. // request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view"); const mayView = context.permissions.has("signage.view");
const settings = useResource( const settings = useResource(
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)), () =>
mayView
? api.get<SettingsResponse>("settings")
: Promise.resolve(undefined),
[mayView], [mayView],
); );
const groups = useResource( const groups = useResource(
@@ -51,7 +52,9 @@ export function SignageDashboard({
const [groupId, setGroupId] = useState<string | undefined>(undefined); const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage"); const mayManageAssets = context.permissions.has("signage.assets.manage");
const mayManageTimelines = context.permissions.has("signage.timelines.manage"); const mayManageTimelines = context.permissions.has(
"signage.timelines.manage",
);
const mayControl = context.permissions.has("signage.playback.control"); const mayControl = context.permissions.has("signage.playback.control");
const mayManageDisplays = context.permissions.has("signage.displays.manage"); const mayManageDisplays = context.permissions.has("signage.displays.manage");
@@ -65,20 +68,31 @@ export function SignageDashboard({
} }
const selectedGroupId = const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); groupId ??
(groups.state.status === "ready"
? groups.state.value?.groups[0]?.id
: undefined);
const selectedGroup = const selectedGroup =
groups.state.status === "ready" groups.state.status === "ready"
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId) ? groups.state.value?.groups.find(
(candidate) => candidate.id === selectedGroupId,
)
: undefined; : undefined;
return ( return (
<> <>
<EkklesiaUiStyles /> <EkklesiaUiStyles />
<DesktopOnly> <DesktopOnly>
<PageHeader
description="Plan visual content, operate playback, and keep every screen connected."
eyebrow="Media"
title="Signage"
/>
{renderLoadable( {renderLoadable(
settings.state, settings.state,
(loaded) => (loaded) =>
loaded === undefined ? undefined : ( loaded === undefined ? undefined : (
<div className="signage-workspace">
<Tabs <Tabs
label="Signage sections" label="Signage sections"
onSelect={setTab} onSelect={setTab}
@@ -104,7 +118,8 @@ export function SignageDashboard({
{...(selectedGroup {...(selectedGroup
? { ? {
activeGroupName: selectedGroup.name, activeGroupName: selectedGroup.name,
activeTimelineId: selectedGroup.playback.activeTimelineId, activeTimelineId:
selectedGroup.playback.activeTimelineId,
} }
: {})} : {})}
api={api} api={api}
@@ -112,10 +127,14 @@ export function SignageDashboard({
onActivate={ onActivate={
mayControl && selectedGroupId !== undefined mayControl && selectedGroupId !== undefined
? async (timelineId) => { ? async (timelineId) => {
await api.send("POST", `playback/${selectedGroupId}/activate`, { await api.send(
"POST",
`playback/${selectedGroupId}/activate`,
{
timelineId, timelineId,
when: "now", when: "now",
}); },
);
groups.reload(); groups.reload();
setTab("playback"); setTab("playback");
} }
@@ -129,7 +148,11 @@ export function SignageDashboard({
}, },
{ {
content: ( content: (
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} /> <ImagesPanel
api={api}
canManage={mayManageAssets}
settings={loaded}
/>
), ),
id: "images", id: "images",
label: "Images", label: "Images",
@@ -140,7 +163,7 @@ export function SignageDashboard({
api={api} api={api}
canManage={mayManageDisplays} canManage={mayManageDisplays}
copyText={copyText} copyText={copyText}
displaySetupUrl={displaySetupUrl} displaySetupUrl={context.display?.url}
onGroupsChanged={() => { onGroupsChanged={() => {
setGroupId(undefined); setGroupId(undefined);
groups.reload(); groups.reload();
@@ -151,12 +174,19 @@ export function SignageDashboard({
label: "Displays", label: "Displays",
}, },
{ {
content: <ProvidersPanel api={api} context={context} settings={loaded} />, content: (
<ProvidersPanel
api={api}
context={context}
settings={loaded}
/>
),
id: "providers", id: "providers",
label: "Providers", label: "Providers",
}, },
]} ]}
/> />
</div>
), ),
{ label: "Loading Signage…", onRetry: settings.reload }, { label: "Loading Signage…", onRetry: settings.reload },
)} )}
+3 -3
View File
@@ -19,17 +19,17 @@ export default defineModuleUi({
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
const displaySetupUrl = new URL("/display/signage", globalThis.location.origin).href;
root.render( root.render(
<SignageDashboard <SignageDashboard
context={context} context={context}
copyText={async (value) => { copyText={async (value) => {
if (globalThis.navigator.clipboard === undefined) { if (globalThis.navigator.clipboard === undefined) {
throw new Error("Clipboard access is not available in this browser."); throw new Error(
"Clipboard access is not available in this browser.",
);
} }
await globalThis.navigator.clipboard.writeText(value); await globalThis.navigator.clipboard.writeText(value);
}} }}
displaySetupUrl={displaySetupUrl}
/>, />,
); );
return () => root.unmount(); return () => root.unmount();
+181 -48
View File
@@ -1,9 +1,22 @@
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 ReactElement, useState } from "react";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js"; import { useAction, useResource, useTick } from "../hooks.js";
import type { ChannelGroupView, ViewerHealthView } from "../types.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; const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
@@ -23,13 +36,19 @@ function DisplayRow({
readonly viewer: ViewerHealthView; readonly viewer: ViewerHealthView;
}): ReactElement { }): ReactElement {
return ( return (
<div className="signage-display-row" data-testid={`signage-display-${viewer.id}`}> <div
className="signage-display-row"
data-testid={`signage-display-${viewer.id}`}
>
<div className="signage-display-row__identity"> <div className="signage-display-row__identity">
{canManage ? ( {canManage ? (
<TextInput <TextInput
aria-label={`Name for ${viewer.name}`} aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name} defaultValue={viewer.name}
onBlur={(event) => event.target.value !== viewer.name && onNameChange(event.target.value)} onBlur={(event) =>
event.target.value !== viewer.name &&
onNameChange(event.target.value)
}
/> />
) : ( ) : (
<strong>{viewer.name}</strong> <strong>{viewer.name}</strong>
@@ -63,10 +82,15 @@ function DisplayRow({
<div className="signage-display-row__health"> <div className="signage-display-row__health">
<span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span> <span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span>
<Badge tone={viewer.offlineReady ? "success" : "neutral"}> <Badge tone={viewer.offlineReady ? "success" : "neutral"}>
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"} {viewer.offlineReady
? `${viewer.cachedAssetCount} cached`
: "Not offline ready"}
</Badge> </Badge>
{viewer.lastError === null ? undefined : ( {viewer.lastError === null ? undefined : (
<span className="signage-display-row__error" title={viewer.lastError.detail ?? undefined}> <span
className="signage-display-row__error"
title={viewer.lastError.detail ?? undefined}
>
{viewer.lastError.code} {viewer.lastError.code}
</span> </span>
)} )}
@@ -89,11 +113,17 @@ export function DisplaysPanel({
readonly api: SignageApi; readonly api: SignageApi;
readonly canManage: boolean; readonly canManage: boolean;
readonly copyText: (value: string) => Promise<void>; readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string; readonly displaySetupUrl: string | undefined;
readonly onGroupsChanged?: () => void; readonly onGroupsChanged?: () => void;
}): ReactElement { }): ReactElement {
const groups = useResource(() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), []); const groups = useResource(
const viewers = useResource(() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), []); () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
);
const viewers = useResource(
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
[],
);
const edit = useAction(() => { const edit = useAction(() => {
groups.reload(); groups.reload();
viewers.reload(); viewers.reload();
@@ -101,8 +131,12 @@ export function DisplaysPanel({
}); });
const viewerEdit = useAction(viewers.reload); const viewerEdit = useAction(viewers.reload);
const [groupName, setGroupName] = useState(""); const [groupName, setGroupName] = useState("");
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle"); const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">(
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined); "idle",
);
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(
undefined,
);
const [channelDraft, setChannelDraft] = useState<{ const [channelDraft, setChannelDraft] = useState<{
groupId: string; groupId: string;
key: string; key: string;
@@ -114,9 +148,13 @@ export function DisplaysPanel({
}); });
useTick(5000); useTick(5000);
const nowMs = Date.now(); const nowMs = Date.now();
const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : []; const readyViewers =
viewers.state.status === "ready" ? viewers.state.value.viewers : [];
const updateViewer = (viewerId: string, changes: Record<string, string>): void => { const updateViewer = (
viewerId: string,
changes: Record<string, string>,
): void => {
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes)); viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
}; };
@@ -124,6 +162,7 @@ export function DisplaysPanel({
<div className="signage-columns"> <div className="signage-columns">
<Panel <Panel
actions={ actions={
displaySetupUrl === undefined ? undefined : (
<div className="signage-pairing-actions"> <div className="signage-pairing-actions">
<a <a
className="ek-button ek-focusable ek-button--primary" className="ek-button ek-focusable ek-button--primary"
@@ -145,24 +184,38 @@ export function DisplaysPanel({
Copy URL Copy URL
</Button> </Button>
</div> </div>
)
} }
description="Connect a screen in three short steps." description="Connect a screen in three short steps."
title="Pair a display" title="Pair a display"
> >
<ol className="signage-pairing-steps" aria-label="Display pairing steps"> <ol
className="signage-pairing-steps"
aria-label="Display pairing steps"
>
<li> <li>
<span className="signage-pairing-steps__number">1</span> <span className="signage-pairing-steps__number">1</span>
<span>Open display setup on the screen that will show signage.</span> <span>
Open display setup on the screen that will show signage.
</span>
</li> </li>
<li> <li>
<span className="signage-pairing-steps__number">2</span> <span className="signage-pairing-steps__number">2</span>
<span>Enter its six-digit code under Display devices in the Dashboard sidebar.</span> <span>
Enter its six-digit code under Display devices in the Dashboard
sidebar.
</span>
</li> </li>
<li> <li>
<span className="signage-pairing-steps__number">3</span> <span className="signage-pairing-steps__number">3</span>
<span>Name the display and choose its audience channel below.</span> <span>Name the display and choose its audience channel below.</span>
</li> </li>
</ol> </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"> <span className="signage-copy-status" aria-live="polite">
{copyStatus === "copied" {copyStatus === "copied"
? "Display setup URL copied." ? "Display setup URL copied."
@@ -170,10 +223,13 @@ export function DisplaysPanel({
? "Could not copy the URL. Copy it from the newly opened setup page." ? "Could not copy the URL. Copy it from the newly opened setup page."
: ""} : ""}
</span> </span>
{viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? ( {viewers.state.status === "ready" &&
viewers.state.value.viewers.length === 0 ? (
<div className="signage-display-empty"> <div className="signage-display-empty">
<strong>No displays paired yet</strong> <strong>No displays paired yet</strong>
<span>A newly approved screen appears below in its assigned channel.</span> <span>
A newly approved screen appears below in its assigned channel.
</span>
</div> </div>
) : undefined} ) : undefined}
</Panel> </Panel>
@@ -216,10 +272,14 @@ export function DisplaysPanel({
groups.state, groups.state,
(value) => { (value) => {
const knownChannelIds = new Set( const knownChannelIds = new Set(
value.groups.flatMap((group) => group.channels.map((channel) => channel.id)), value.groups.flatMap((group) =>
group.channels.map((channel) => channel.id),
),
); );
const unassigned = readyViewers.filter( const unassigned = readyViewers.filter(
(viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId), (viewer) =>
viewer.channelId === null ||
!knownChannelIds.has(viewer.channelId),
); );
return ( return (
<div className="signage-groups" data-testid="signage-groups"> <div className="signage-groups" data-testid="signage-groups">
@@ -227,12 +287,18 @@ export function DisplaysPanel({
<article className="signage-group" key={group.id}> <article className="signage-group" key={group.id}>
<header className="signage-group__header"> <header className="signage-group__header">
<div className="signage-group__identity"> <div className="signage-group__identity">
<span className="signage-hierarchy-label">Channel group</span> <span className="signage-hierarchy-label">
Channel group
</span>
<h3>{group.name}</h3> <h3>{group.name}</h3>
</div> </div>
<Badge tone="info">{group.playback.transport}</Badge> <Badge tone="info">{group.playback.transport}</Badge>
{canManage && value.groups.length > 1 ? ( {canManage && value.groups.length > 1 ? (
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(group.id)} variant="danger"> <Button
disabled={edit.pending}
onClick={() => setConfirmGroupId(group.id)}
variant="danger"
>
Delete group Delete group
</Button> </Button>
) : canManage ? ( ) : canManage ? (
@@ -242,17 +308,24 @@ export function DisplaysPanel({
{confirmGroupId === group.id ? ( {confirmGroupId === group.id ? (
<div className="signage-confirm" role="alert"> <div className="signage-confirm" role="alert">
<p> <p>
Delete {group.name}? Its playback history will be removed. Displays assigned here will move to Delete {group.name}? Its playback history will be
the default channel of another group. removed. Displays assigned here will move to the
default channel of another group.
</p> </p>
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}> <Button
disabled={edit.pending}
onClick={() => setConfirmGroupId(undefined)}
>
Cancel Cancel
</Button> </Button>
<Button <Button
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(async () => { edit.run(async () => {
await api.send("DELETE", `channel-groups/${group.id}`); await api.send(
"DELETE",
`channel-groups/${group.id}`,
);
setConfirmGroupId(undefined); setConfirmGroupId(undefined);
}) })
} }
@@ -266,21 +339,34 @@ export function DisplaysPanel({
<section className="signage-audiences"> <section className="signage-audiences">
<div className="signage-audiences__heading"> <div className="signage-audiences__heading">
<div> <div>
<span className="signage-hierarchy-label">Audience channels</span> <span className="signage-hierarchy-label">
Audience channels
</span>
<h4>Who this group renders for</h4> <h4>Who this group renders for</h4>
</div> </div>
<span>Displays inherit the channel they are assigned to.</span> <span>
Displays inherit the channel they are assigned to.
</span>
</div> </div>
<div className="signage-channel-list"> <div className="signage-channel-list">
{group.channels.map((channel) => { {group.channels.map((channel) => {
const assigned = readyViewers.filter((viewer) => viewer.channelId === channel.id); const assigned = readyViewers.filter(
(viewer) => viewer.channelId === channel.id,
);
return ( return (
<article className="signage-channel" key={channel.id}> <article
className="signage-channel"
key={channel.id}
>
<header className="signage-channel__header"> <header className="signage-channel__header">
<div> <div>
<h5>{channel.name}</h5> <h5>{channel.name}</h5>
<div className="signage-channel__meta"> <div className="signage-channel__meta">
{channel.isDefault ? <Badge tone="success">Default channel</Badge> : undefined} {channel.isDefault ? (
<Badge tone="success">
Default channel
</Badge>
) : undefined}
<span>Audience key</span> <span>Audience key</span>
<code>{channel.key}</code> <code>{channel.key}</code>
<Badge tone="neutral"> <Badge tone="neutral">
@@ -291,7 +377,14 @@ export function DisplaysPanel({
{canManage && !channel.isDefault ? ( {canManage && !channel.isDefault ? (
<Button <Button
disabled={edit.pending} disabled={edit.pending}
onClick={() => edit.run(() => api.send("DELETE", `channels/${channel.id}`))} onClick={() =>
edit.run(() =>
api.send(
"DELETE",
`channels/${channel.id}`,
),
)
}
variant="danger" variant="danger"
> >
Remove channel Remove channel
@@ -299,9 +392,13 @@ export function DisplaysPanel({
) : undefined} ) : undefined}
</header> </header>
<div className="signage-channel__displays"> <div className="signage-channel__displays">
<span className="signage-hierarchy-label">Displays</span> <span className="signage-hierarchy-label">
Displays
</span>
{assigned.length === 0 ? ( {assigned.length === 0 ? (
<p className="signage-channel__empty">No displays assigned.</p> <p className="signage-channel__empty">
No displays assigned.
</p>
) : ( ) : (
assigned.map((viewer) => ( assigned.map((viewer) => (
<DisplayRow <DisplayRow
@@ -309,8 +406,12 @@ export function DisplaysPanel({
groups={value.groups} groups={value.groups}
key={viewer.id} key={viewer.id}
nowMs={nowMs} nowMs={nowMs}
onChannelChange={(channelId) => updateViewer(viewer.id, { channelId })} onChannelChange={(channelId) =>
onNameChange={(name) => updateViewer(viewer.id, { name })} updateViewer(viewer.id, { channelId })
}
onNameChange={(name) =>
updateViewer(viewer.id, { name })
}
viewer={viewer} viewer={viewer}
/> />
)) ))
@@ -322,12 +423,20 @@ export function DisplaysPanel({
</div> </div>
{canManage ? ( {canManage ? (
<div className="signage-channel-form" data-testid="signage-add-channel"> <div
className="signage-channel-form"
data-testid="signage-add-channel"
>
<div className="signage-channel-form__heading"> <div className="signage-channel-form__heading">
<h5>Add an audience channel</h5> <h5>Add an audience channel</h5>
<span>The default channel always remains available.</span> <span>
The default channel always remains available.
</span>
</div> </div>
<Field hint="Shown to administrators and when assigning displays." label="Channel name"> <Field
hint="Shown to administrators and when assigning displays."
label="Channel name"
>
{(control) => ( {(control) => (
<TextInput <TextInput
{...control} {...control}
@@ -335,12 +444,19 @@ export function DisplaysPanel({
onChange={(event) => onChange={(event) =>
setChannelDraft((current) => ({ setChannelDraft((current) => ({
groupId: group.id, groupId: group.id,
key: current.groupId === group.id ? current.key : "", key:
current.groupId === group.id
? current.key
: "",
name: event.target.value, name: event.target.value,
})) }))
} }
placeholder="Online audience" placeholder="Online audience"
value={channelDraft.groupId === group.id ? channelDraft.name : ""} value={
channelDraft.groupId === group.id
? channelDraft.name
: ""
}
/> />
)} )}
</Field> </Field>
@@ -356,12 +472,19 @@ export function DisplaysPanel({
setChannelDraft((current) => ({ setChannelDraft((current) => ({
groupId: group.id, groupId: group.id,
key: event.target.value, key: event.target.value,
name: current.groupId === group.id ? current.name : "", name:
current.groupId === group.id
? current.name
: "",
})) }))
} }
pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
placeholder="online" placeholder="online"
value={channelDraft.groupId === group.id ? channelDraft.key : ""} value={
channelDraft.groupId === group.id
? channelDraft.key
: ""
}
/> />
)} )}
</Field> </Field>
@@ -375,10 +498,14 @@ export function DisplaysPanel({
} }
onClick={() => onClick={() =>
edit.run(async () => { edit.run(async () => {
await api.send("POST", `channel-groups/${group.id}/channels`, { await api.send(
"POST",
`channel-groups/${group.id}/channels`,
{
key: channelDraft.key.trim(), key: channelDraft.key.trim(),
name: channelDraft.name.trim(), name: channelDraft.name.trim(),
}); },
);
setChannelDraft({ setChannelDraft({
groupId: "", groupId: "",
key: "", key: "",
@@ -398,15 +525,21 @@ export function DisplaysPanel({
{unassigned.length === 0 ? undefined : ( {unassigned.length === 0 ? undefined : (
<section className="signage-unassigned"> <section className="signage-unassigned">
<span className="signage-hierarchy-label">Needs assignment</span> <span className="signage-hierarchy-label">
Needs assignment
</span>
{unassigned.map((viewer) => ( {unassigned.map((viewer) => (
<DisplayRow <DisplayRow
canManage={canManage} canManage={canManage}
groups={value.groups} groups={value.groups}
key={viewer.id} key={viewer.id}
nowMs={nowMs} nowMs={nowMs}
onChannelChange={(channelId) => updateViewer(viewer.id, { channelId })} onChannelChange={(channelId) =>
onNameChange={(name) => updateViewer(viewer.id, { name })} updateViewer(viewer.id, { channelId })
}
onNameChange={(name) =>
updateViewer(viewer.id, { name })
}
viewer={viewer} viewer={viewer}
/> />
))} ))}
+92 -24
View File
@@ -1,5 +1,13 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; 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 ReactElement, useState } from "react";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js"; import { useAction, useResource, useTick } from "../hooks.js";
@@ -10,7 +18,12 @@ import type {
TimelineSummary, TimelineSummary,
} from "../types.js"; } from "../types.js";
import { PreviewPanel } from "./PreviewPanel.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. * Live transport for one channel group.
@@ -75,13 +88,14 @@ export function PlaybackPanel({
return timelineId; return timelineId;
} }
return ( return (
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ?? timelines.state.value.timelines.find(
timelineId (timeline) => timeline.id === timelineId,
)?.name ?? timelineId
); );
} }
return ( return (
<div className="signage-columns"> <div className="signage-columns signage-playback-layout">
<Panel <Panel
actions={renderLoadable( actions={renderLoadable(
groups.state, groups.state,
@@ -116,7 +130,10 @@ export function PlaybackPanel({
value === undefined ? ( value === undefined ? (
<EmptyState title="No channel group selected" /> <EmptyState title="No channel group selected" />
) : ( ) : (
<div className="signage-playback" data-testid="signage-playback"> <div
className="signage-playback"
data-testid="signage-playback"
>
<div className="signage-playback__status"> <div className="signage-playback__status">
{value.snapshot.activeTimelineId === null ? undefined : ( {value.snapshot.activeTimelineId === null ? undefined : (
<Badge tone="success"> <Badge tone="success">
@@ -134,7 +151,9 @@ export function PlaybackPanel({
> >
{value.snapshot.transport} {value.snapshot.transport}
</Badge> </Badge>
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined} {value.snapshot.blackout ? (
<Badge tone="danger">Blackout</Badge>
) : undefined}
{value.current?.stale === true ? ( {value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge> <Badge tone="warning">Stale</Badge>
) : undefined} ) : undefined}
@@ -152,7 +171,12 @@ export function PlaybackPanel({
title="Nothing on screen" title="Nothing on screen"
/> />
) : ( ) : (
<dl className="signage-playback__current" data-testid="signage-current"> <section className="signage-playback__now">
<h3>Now showing</h3>
<dl
className="signage-playback__current"
data-testid="signage-current"
>
<dt>Provider</dt> <dt>Provider</dt>
<dd>{value.current.providerId}</dd> <dd>{value.current.providerId}</dd>
<dt>Item</dt> <dt>Item</dt>
@@ -168,13 +192,26 @@ export function PlaybackPanel({
: formatDuration(value.snapshot.remainingMs)} : formatDuration(value.snapshot.remainingMs)}
</dd> </dd>
<dt>Started</dt> <dt>Started</dt>
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd> <dd>
{formatInstant(
value.current.startedAtMs,
settings.timezone,
)}
</dd>
</dl> </dl>
</section>
)} )}
{canControl ? ( {canControl ? (
<div className="signage-playback__controls" data-testid="signage-controls"> <ActionBar
<Button disabled={command.pending} onClick={() => run("previous")}> className="signage-playback__controls"
data-testid="signage-controls"
label="Playback controls"
>
<Button
disabled={command.pending}
onClick={() => run("previous")}
>
Previous Previous
</Button> </Button>
{value.snapshot.transport === "paused" ? ( {value.snapshot.transport === "paused" ? (
@@ -186,17 +223,27 @@ export function PlaybackPanel({
Resume Resume
</Button> </Button>
) : ( ) : (
<Button disabled={command.pending} onClick={() => run("pause")}> <Button
disabled={command.pending}
onClick={() => run("pause")}
>
Pause Pause
</Button> </Button>
)} )}
<Button disabled={command.pending} onClick={() => run("next")}> <Button
disabled={command.pending}
onClick={() => run("next")}
>
Next Next
</Button> </Button>
<Button <Button
disabled={command.pending} disabled={command.pending}
onClick={() => run("blackout", { on: !value.snapshot.blackout })} onClick={() =>
variant={value.snapshot.blackout ? "primary" : "secondary"} run("blackout", { on: !value.snapshot.blackout })
}
variant={
value.snapshot.blackout ? "primary" : "secondary"
}
> >
{value.snapshot.blackout ? "End blackout" : "Blackout"} {value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button> </Button>
@@ -208,10 +255,13 @@ export function PlaybackPanel({
Release hold Release hold
</Button> </Button>
)} )}
<Button disabled={command.pending} onClick={() => run("reload")}> <Button
disabled={command.pending}
onClick={() => run("reload")}
>
Reload content Reload content
</Button> </Button>
</div> </ActionBar>
) : undefined} ) : undefined}
{canControl ? ( {canControl ? (
@@ -224,12 +274,17 @@ export function PlaybackPanel({
{(control) => ( {(control) => (
<Select <Select
{...control} {...control}
onChange={(event) => setActivateId(event.target.value)} onChange={(event) =>
setActivateId(event.target.value)
}
value={activateId} value={activateId}
> >
<option value="">Choose a timeline…</option> <option value="">Choose a timeline…</option>
{loaded.timelines.map((timeline) => ( {loaded.timelines.map((timeline) => (
<option key={timeline.id} value={timeline.id}> <option
key={timeline.id}
value={timeline.id}
>
{timeline.name} {timeline.name}
</option> </option>
))} ))}
@@ -239,7 +294,10 @@ export function PlaybackPanel({
<Button <Button
disabled={command.pending || activateId === ""} disabled={command.pending || activateId === ""}
onClick={() => onClick={() =>
run("activate", { timelineId: activateId, when: "now" }) run("activate", {
timelineId: activateId,
when: "now",
})
} }
variant="primary" variant="primary"
> >
@@ -248,7 +306,10 @@ export function PlaybackPanel({
<Button <Button
disabled={command.pending || activateId === ""} disabled={command.pending || activateId === ""}
onClick={() => onClick={() =>
run("activate", { timelineId: activateId, when: "next-boundary" }) run("activate", {
timelineId: activateId,
when: "next-boundary",
})
} }
> >
At next slot At next slot
@@ -262,11 +323,18 @@ export function PlaybackPanel({
{value.history.length === 0 ? undefined : ( {value.history.length === 0 ? undefined : (
<div className="signage-playback__history"> <div className="signage-playback__history">
<h4 className="signage-editor__subtitle">Recently played</h4> <h4 className="signage-editor__subtitle">
Recently played
</h4>
<ul className="ek-list"> <ul className="ek-list">
{value.history.slice(0, 8).map((entry) => ( {value.history.slice(0, 8).map((entry) => (
<li className="ek-list__item signage-row" key={entry.id}> <li
<span className="signage-row__name">{entry.itemId}</span> className="ek-list__item signage-row"
key={entry.id}
>
<span className="signage-row__name">
{entry.itemId}
</span>
<span className="signage-playback__history-meta"> <span className="signage-playback__history-meta">
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`} {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
</span> </span>
+23 -8
View File
@@ -44,12 +44,17 @@ export function ProvidersPanel({
[], [],
); );
const published = new Set( const published = new Set(
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id), context.contributions
.list(SLIDE_RENDERER_CAPABILITY)
.map((contribution) => contribution.id),
); );
return ( return (
<Panel <Panel
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`} actions={
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
}
description="Running content sources available to timelines."
title="Content providers" title="Content providers"
> >
{renderLoadable( {renderLoadable(
@@ -73,20 +78,27 @@ export function ProvidersPanel({
</TableHead> </TableHead>
<TableBody> <TableBody>
{value.providers.map((provider) => { {value.providers.map((provider) => {
const rendererPublished = published.has(provider.renderer.contributionId); const rendererPublished = published.has(
const isNative = provider.renderer.contributionId === "signage.native-image"; provider.renderer.contributionId,
);
const isNative =
provider.renderer.contributionId === "signage.native-image";
return ( return (
<TableRow key={provider.providerId}> <TableRow key={provider.providerId}>
<TableCell> <TableCell>
<span className="signage-provider__id">{provider.providerId}</span> <span className="signage-provider__id">
{provider.providerId}
</span>
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span> <span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
</TableCell> </TableCell>
<TableCell>{provider.contractVersion}</TableCell> <TableCell>{provider.contractVersion}</TableCell>
<TableCell> <TableCell>
{isNative ? ( {isNative ? (
<Badge tone="success">Signage renders this</Badge> <Badge tone="info">Built in</Badge>
) : rendererPublished ? ( ) : rendererPublished ? (
<Badge tone="success">{provider.renderer.contributionId}</Badge> <Badge tone="success">
{provider.renderer.contributionId}
</Badge>
) : ( ) : (
<Badge tone="warning">Renderer not published</Badge> <Badge tone="warning">Renderer not published</Badge>
)} )}
@@ -101,7 +113,10 @@ export function ProvidersPanel({
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{formatInstant(provider.lastSuccessAt, settings.timezone)} {formatInstant(
provider.lastSuccessAt,
settings.timezone,
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
+103 -29
View File
@@ -25,7 +25,12 @@ import type {
TimelineDetail, TimelineDetail,
TimelineSummary, TimelineSummary,
} from "../types.js"; } from "../types.js";
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js"; import {
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
const NATIVE_PROVIDER_ID = "signage.native-images"; const NATIVE_PROVIDER_ID = "signage.native-images";
@@ -87,7 +92,9 @@ export function TimelinesPanel({
<Button <Button
disabled={create.pending || newName.trim().length === 0} disabled={create.pending || newName.trim().length === 0}
onClick={() => onClick={() =>
create.run(() => api.send("POST", "timelines", { name: newName.trim() })) create.run(() =>
api.send("POST", "timelines", { name: newName.trim() }),
)
} }
variant="primary" variant="primary"
> >
@@ -119,17 +126,31 @@ export function TimelinesPanel({
</TableHead> </TableHead>
<TableBody> <TableBody>
{value.timelines.map((timeline) => ( {value.timelines.map((timeline) => (
<TableRow key={timeline.id} selected={timeline.id === selectedId}> <TableRow
key={timeline.id}
selected={timeline.id === selectedId}
>
<TableCell> <TableCell>
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button> <div className="signage-timeline-name">
<Button
className="signage-timeline-select"
onClick={() => setSelectedId(timeline.id)}
variant="quiet"
>
{timeline.name}
</Button>
{timeline.id === activeTimelineId ? ( {timeline.id === activeTimelineId ? (
<Badge tone="success"> <Badge tone="success">
{activeGroupName ? `Active in ${activeGroupName}` : "Active"} {activeGroupName
? `Active in ${activeGroupName}`
: "Active"}
</Badge> </Badge>
) : undefined} ) : undefined}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
{timeline.eligibleFrom === null && timeline.eligibleUntil === null {timeline.eligibleFrom === null &&
timeline.eligibleUntil === null
? "Always" ? "Always"
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`} : `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
</TableCell> </TableCell>
@@ -137,11 +158,18 @@ export function TimelinesPanel({
<TableCell> <TableCell>
{onActivate === undefined ? undefined : ( {onActivate === undefined ? undefined : (
<Button <Button
disabled={activation.pending || timeline.id === activeTimelineId} disabled={
onClick={() => activation.run(() => onActivate(timeline.id))} activation.pending ||
timeline.id === activeTimelineId
}
onClick={() =>
activation.run(() => onActivate(timeline.id))
}
variant="primary" variant="primary"
> >
{timeline.id === activeTimelineId ? "Active" : "Activate"} {timeline.id === activeTimelineId
? "Active"
: "Activate"}
</Button> </Button>
)} )}
</TableCell> </TableCell>
@@ -163,7 +191,11 @@ export function TimelinesPanel({
key={selectedId} key={selectedId}
onChanged={timelines.reload} onChanged={timelines.reload}
onClose={() => setSelectedId(undefined)} onClose={() => setSelectedId(undefined)}
providers={providers.state.status === "ready" ? providers.state.value.providers : []} providers={
providers.state.status === "ready"
? providers.state.value.providers
: []
}
settings={settings} settings={settings}
timelineId={selectedId} timelineId={selectedId}
/> />
@@ -193,13 +225,18 @@ function TimelineEditor({
() => api.get<TimelineDetail>(`timelines/${timelineId}`), () => api.get<TimelineDetail>(`timelines/${timelineId}`),
[timelineId], [timelineId],
); );
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []); const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("images"),
[],
);
const edit = useAction(() => { const edit = useAction(() => {
detail.reload(); detail.reload();
onChanged(); onChanged();
}); });
const [providerId, setProviderId] = useState(""); const [providerId, setProviderId] = useState("");
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]); const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>(
[],
);
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]); const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
function loadProvider(nextProviderId: string): void { function loadProvider(nextProviderId: string): void {
@@ -236,7 +273,10 @@ function TimelineEditor({
<h3 className="signage-editor__title">{value.timeline.name}</h3> <h3 className="signage-editor__title">{value.timeline.name}</h3>
{canManage ? ( {canManage ? (
<div className="signage-editor__add" data-testid="signage-add-source"> <div
className="signage-editor__add"
data-testid="signage-add-source"
>
<Field <Field
hint="A provider's own source. Repeat its slot to give it more turns in one rotation." hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
label="Content provider" label="Content provider"
@@ -249,7 +289,10 @@ function TimelineEditor({
> >
<option value="">Choose a provider…</option> <option value="">Choose a provider…</option>
{providers.map((provider) => ( {providers.map((provider) => (
<option key={provider.providerId} value={provider.providerId}> <option
key={provider.providerId}
value={provider.providerId}
>
{provider.providerId} {provider.providerId}
</option> </option>
))} ))}
@@ -305,9 +348,13 @@ function TimelineEditor({
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, { api.send(
"POST",
`timelines/${timelineId}/slots`,
{
sourceId: source.id, sourceId: source.id,
}), },
),
) )
} }
> >
@@ -317,7 +364,10 @@ function TimelineEditor({
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`), api.send(
"DELETE",
`timelines/${timelineId}/sources/${source.id}`,
),
) )
} }
variant="danger" variant="danger"
@@ -353,7 +403,8 @@ function TimelineEditor({
(candidate) => candidate.id === slot.sourceId, (candidate) => candidate.id === slot.sourceId,
); );
const image = const image =
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready" slot.providerId === NATIVE_PROVIDER_ID &&
images.state.status === "ready"
? images.state.value.images.find( ? images.state.value.images.find(
(candidate) => candidate.id === slot.itemId, (candidate) => candidate.id === slot.itemId,
) )
@@ -364,7 +415,8 @@ function TimelineEditor({
<TableCell> <TableCell>
{slot.kind === "provider-source" {slot.kind === "provider-source"
? (source?.label ?? "Removed source") ? (source?.label ?? "Removed source")
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)} : (image?.displayName ??
`${slot.providerId} · ${slot.itemId}`)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{slot.durationOverrideMs === null {slot.durationOverrideMs === null
@@ -378,9 +430,13 @@ function TimelineEditor({
label={slot.enabled ? "On" : "Off"} label={slot.enabled ? "On" : "Off"}
onChange={(event) => onChange={(event) =>
edit.run(() => edit.run(() =>
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, { api.send(
"PATCH",
`timelines/${timelineId}/slots/${slot.id}`,
{
enabled: event.target.checked, enabled: event.target.checked,
}), },
),
) )
} }
/> />
@@ -392,29 +448,44 @@ function TimelineEditor({
disabled={index === 0 || edit.pending} disabled={index === 0 || edit.pending}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, { api.send(
"PUT",
`timelines/${timelineId}/slot-order`,
{
slotIds: swap( slotIds: swap(
value.slots.map((candidate) => candidate.id), value.slots.map(
(candidate) => candidate.id,
),
index, index,
index - 1, index - 1,
), ),
}), },
),
) )
} }
> >
Up Up
</Button> </Button>
<Button <Button
disabled={index === value.slots.length - 1 || edit.pending} disabled={
index === value.slots.length - 1 ||
edit.pending
}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, { api.send(
"PUT",
`timelines/${timelineId}/slot-order`,
{
slotIds: swap( slotIds: swap(
value.slots.map((candidate) => candidate.id), value.slots.map(
(candidate) => candidate.id,
),
index, index,
index + 1, index + 1,
), ),
}), },
),
) )
} }
> >
@@ -424,7 +495,10 @@ function TimelineEditor({
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`), api.send(
"DELETE",
`timelines/${timelineId}/slots/${slot.id}`,
),
) )
} }
variant="danger" variant="danger"
+77 -15
View File
@@ -28,7 +28,7 @@
} }
:host { :host {
--signage-gap: var(--ekklesia-spacing-lg); --signage-gap: var(--ekklesia-spacing-xl);
--signage-gap-tight: var(--ekklesia-spacing-sm); --signage-gap-tight: var(--ekklesia-spacing-sm);
--signage-panel-radius: var(--ekklesia-radius-lg); --signage-panel-radius: var(--ekklesia-radius-lg);
--signage-thumbnail-width: 220px; --signage-thumbnail-width: 220px;
@@ -69,10 +69,19 @@
.signage-desktop { .signage-desktop {
display: grid; display: grid;
gap: var(--signage-gap); gap: var(--ekklesia-spacing-xl);
} }
} }
.signage-workspace {
display: grid;
min-width: 0;
}
.signage-workspace > .ek-tabs__list {
padding-inline: var(--ekklesia-spacing-xs);
}
.signage-columns { .signage-columns {
display: grid; display: grid;
gap: var(--signage-gap); gap: var(--signage-gap);
@@ -167,7 +176,10 @@
.signage-image-grid { .signage-image-grid {
display: grid; display: grid;
gap: var(--signage-gap); gap: var(--signage-gap);
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr)); grid-template-columns: repeat(
auto-fill,
minmax(var(--signage-thumbnail-width), 1fr)
);
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -177,13 +189,13 @@
/* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather /* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather
than spread between the thumbnail and the name. */ than spread between the thumbnail and the name. */
align-content: start; align-content: start;
background: var(--ekklesia-background-canvas); background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md); border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--ekklesia-spacing-xs); gap: var(--ekklesia-spacing-sm);
overflow: hidden; overflow: hidden;
padding: var(--ekklesia-spacing-sm); padding: var(--ekklesia-spacing-md);
} }
/* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and /* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and
@@ -285,6 +297,19 @@
gap: var(--ekklesia-spacing-2xs); gap: var(--ekklesia-spacing-2xs);
} }
.signage-timeline-name {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-sm);
}
.signage-timeline-select {
color: var(--ekklesia-text-primary);
font-weight: var(--ekklesia-typography-weight-bold);
padding-inline: var(--ekklesia-spacing-sm);
}
/* --------------------------------------------------------------------------------------------- /* ---------------------------------------------------------------------------------------------
* Playback. * Playback.
* ------------------------------------------------------------------------------------------- */ * ------------------------------------------------------------------------------------------- */
@@ -311,6 +336,19 @@
row-gap: var(--ekklesia-spacing-2xs); row-gap: var(--ekklesia-spacing-2xs);
} }
.signage-playback__now {
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--ekklesia-spacing-sm);
padding: var(--ekklesia-spacing-md);
}
.signage-playback__now h3 {
font-size: var(--ekklesia-typography-size-lg);
margin: 0;
}
.signage-playback__current dt { .signage-playback__current dt {
color: var(--ekklesia-text-secondary); color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm); font-size: var(--ekklesia-typography-size-sm);
@@ -326,6 +364,23 @@
font-size: var(--ekklesia-typography-size-sm); font-size: var(--ekklesia-typography-size-sm);
} }
.signage-playback__history {
display: grid;
gap: var(--ekklesia-spacing-sm);
}
.signage-playback__history .ek-list {
max-height: 14rem;
overflow: auto;
}
@media (min-width: 1280px) {
.signage-playback-layout {
align-items: start;
grid-template-columns: minmax(0, 1fr) minmax(28rem, 1fr);
}
}
/* --------------------------------------------------------------------------------------------- /* ---------------------------------------------------------------------------------------------
* Displays and providers. * Displays and providers.
* ------------------------------------------------------------------------------------------- */ * ------------------------------------------------------------------------------------------- */
@@ -365,7 +420,7 @@
.signage-pairing-steps li { .signage-pairing-steps li {
align-items: center; align-items: center;
background: var(--ekklesia-background-inset); background: var(--ekklesia-accent-subtle);
border-radius: var(--ekklesia-radius-md); border-radius: var(--ekklesia-radius-md);
display: flex; display: flex;
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
@@ -403,7 +458,7 @@
} }
.signage-group { .signage-group {
background: var(--ekklesia-background-canvas); background: var(--ekklesia-background-inset);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--signage-panel-radius); border-radius: var(--signage-panel-radius);
display: grid; display: grid;
@@ -458,7 +513,8 @@
} }
.signage-audiences { .signage-audiences {
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); border-block-start: var(--ekklesia-border-width) solid
var(--ekklesia-border-divider);
display: grid; display: grid;
gap: var(--signage-gap); gap: var(--signage-gap);
padding-block-start: var(--signage-gap); padding-block-start: var(--signage-gap);
@@ -489,11 +545,12 @@
.signage-channel { .signage-channel {
background: var(--ekklesia-background-surface); background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-inline-start: var(--ekklesia-spacing-2xs) solid var(--ekklesia-accent-base); border-inline-start: var(--ekklesia-spacing-2xs) solid
var(--ekklesia-accent-base);
border-radius: var(--ekklesia-radius-md); border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
margin-inline-start: var(--ekklesia-spacing-sm); margin-inline-start: var(--ekklesia-spacing-md);
padding: var(--ekklesia-spacing-md); padding: var(--ekklesia-spacing-md);
} }
@@ -527,7 +584,8 @@
} }
.signage-channel__displays { .signage-channel__displays {
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); border-block-start: var(--ekklesia-border-width) solid
var(--ekklesia-border-divider);
display: grid; display: grid;
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
padding-block-start: var(--signage-gap-tight); padding-block-start: var(--signage-gap-tight);
@@ -545,7 +603,10 @@
border-radius: var(--ekklesia-radius-md); border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(14rem, 1.2fr); grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(
14rem,
1.2fr
);
padding: var(--ekklesia-spacing-sm); padding: var(--ekklesia-spacing-sm);
} }
@@ -573,11 +634,12 @@
.signage-channel-form, .signage-channel-form,
.signage-unassigned { .signage-unassigned {
background: var(--ekklesia-background-inset); background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md); border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
margin-inline-start: var(--ekklesia-spacing-sm); margin-inline-start: var(--ekklesia-spacing-md);
padding: var(--ekklesia-spacing-md); padding: var(--ekklesia-spacing-md);
} }
+327 -76
View File
@@ -3,7 +3,12 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SignageDashboard } from "../../src/ui/SignageDashboard.js"; import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js"; import {
createFakeHost,
errorResponse,
FAKE_SETTINGS,
jsonResponse,
} from "./support/fake-host.js";
/** /**
* The management UI's behaviour, mounted as React rather than inspected as source. * The management UI's behaviour, mounted as React rather than inspected as source.
@@ -14,14 +19,18 @@ import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./su
*/ */
// React needs this to run effects the way a browser does. // React needs this to run effects the way a browser does.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLElement; let container: HTMLElement;
let root: Root; let root: Root;
const DISPLAY_SETUP_URL = "https://dashboard.example.test/display/signage"; const DISPLAY_SETUP_URL = "https://api.example.test/display/signage";
const copiedText: string[] = []; const copiedText: string[] = [];
function dashboard(context: Parameters<typeof SignageDashboard>[0]["context"]): React.ReactElement { function dashboard(
context: Parameters<typeof SignageDashboard>[0]["context"],
): React.ReactElement {
return ( return (
<SignageDashboard <SignageDashboard
context={context} context={context}
@@ -29,7 +38,6 @@ function dashboard(context: Parameters<typeof SignageDashboard>[0]["context"]):
copiedText.push(value); copiedText.push(value);
return Promise.resolve(); return Promise.resolve();
}} }}
displaySetupUrl={DISPLAY_SETUP_URL}
/> />
); );
} }
@@ -85,7 +93,9 @@ function buttonNamed(label: string): HTMLButtonElement | undefined {
} }
const GROUP = { const GROUP = {
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }], channels: [
{ id: "chan-1", isDefault: true, key: "default", name: "Default" },
],
id: "group-1", id: "group-1",
name: "Main", name: "Main",
playback: { playback: {
@@ -165,9 +175,21 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"], permissions: ["signage.view", "signage.playback.control"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{ {
method: "GET", method: "GET",
path: "playback/group-1", path: "playback/group-1",
@@ -186,10 +208,12 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context)); await render(dashboard(host.context));
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain( expect(
"welcome", container.querySelector("[data-testid='signage-current']")?.textContent,
); ).toContain("welcome");
expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s"); expect(
container.querySelector("[data-testid='signage-remaining']")?.textContent,
).toBe("4s");
await click(buttonNamed("Pause")); await click(buttonNamed("Pause"));
await click(buttonNamed("Next")); await click(buttonNamed("Next"));
@@ -206,33 +230,69 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view"], permissions: ["signage.view"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
], ],
}); });
await render(dashboard(host.context)); await render(dashboard(host.context));
// The current slide is still legible; the buttons that would be refused are simply not offered. // The current slide is still legible; the buttons that would be refused are simply not offered.
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull(); expect(
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull(); container.querySelector("[data-testid='signage-current']"),
).not.toBeNull();
expect(
container.querySelector("[data-testid='signage-controls']"),
).toBeNull();
}); });
it("shows which timeline remains active after the dashboard data is reloaded", async () => { it("shows which timeline remains active after the dashboard data is reloaded", async () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"], permissions: ["signage.view", "signage.playback.control"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{ {
method: "GET", method: "GET",
path: "timelines", path: "timelines",
respond: () => jsonResponse({ timelines: [TIMELINE] }), respond: () => jsonResponse({ timelines: [TIMELINE] }),
}, },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, {
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
], ],
}); });
@@ -251,12 +311,20 @@ describe("Signage dashboard", () => {
it("keeps a failed activation visible instead of pretending it succeeded", async () => { it("keeps a failed activation visible instead of pretending it succeeded", async () => {
const inactiveGroup = { const inactiveGroup = {
...GROUP, ...GROUP,
playback: { ...GROUP.playback, activeTimelineId: null, currentSelectionId: null }, playback: {
...GROUP.playback,
activeTimelineId: null,
currentSelectionId: null,
},
}; };
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"], permissions: ["signage.view", "signage.playback.control"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{ {
method: "GET", method: "GET",
path: "channel-groups", path: "channel-groups",
@@ -267,13 +335,25 @@ describe("Signage dashboard", () => {
path: "timelines", path: "timelines",
respond: () => jsonResponse({ timelines: [TIMELINE] }), respond: () => jsonResponse({ timelines: [TIMELINE] }),
}, },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, {
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
{ {
method: "POST", method: "POST",
path: "playback/group-1/activate", path: "playback/group-1/activate",
respond: () => respond: () =>
errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409), errorResponse(
"SIGNAGE_CONFLICT",
"The request conflicts with the current state.",
409,
),
}, },
], ],
}); });
@@ -296,15 +376,40 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"], permissions: ["signage.view", "signage.timelines.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
{ {
method: "POST", method: "POST",
path: "timelines", path: "timelines",
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400), respond: () =>
errorResponse(
"SIGNAGE_INVALID_REQUEST",
"The request is not valid.",
400,
),
}, },
], ],
}); });
@@ -316,7 +421,9 @@ describe("Signage dashboard", () => {
), ),
); );
const input = container.querySelector("input[type='text']") as HTMLInputElement; const input = container.querySelector(
"input[type='text']",
) as HTMLInputElement;
await type(input, "Sunday morning"); await type(input, "Sunday morning");
await click(buttonNamed("Create")); await click(buttonNamed("Create"));
@@ -330,10 +437,26 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"], permissions: ["signage.view", "signage.timelines.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, path: "settings",
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
{
method: "GET",
path: "images",
respond: () => jsonResponse({ images: [] }),
},
{ {
method: "GET", method: "GET",
path: "timelines", path: "timelines",
@@ -381,7 +504,9 @@ describe("Signage dashboard", () => {
); );
await click(buttonNamed("Sunday morning")); await click(buttonNamed("Sunday morning"));
expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull(); expect(
container.querySelector("[data-testid='signage-timeline-editor']"),
).not.toBeNull();
expect(container.textContent).toContain("This timeline has no slots"); expect(container.textContent).toContain("This timeline has no slots");
}); });
@@ -392,10 +517,26 @@ describe("Signage dashboard", () => {
contributions: [], contributions: [],
permissions: ["signage.view"], permissions: ["signage.view"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{ {
method: "GET", method: "GET",
path: "providers", path: "providers",
@@ -438,11 +579,31 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view"], permissions: ["signage.view"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
], ],
}); });
@@ -454,17 +615,35 @@ describe("Signage dashboard", () => {
); );
expect(container.textContent).toContain("No content providers registered"); expect(container.textContent).toContain("No content providers registered");
expect(container.textContent).toContain("Signage works on its own with native images"); expect(container.textContent).toContain(
"Signage works on its own with native images",
);
}); });
it("shows the display roster with online state and offline readiness", async () => { it("shows the display roster with online state and offline readiness", async () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"], permissions: ["signage.view", "signage.displays.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{ {
method: "GET", method: "GET",
path: "viewers", path: "viewers",
@@ -476,7 +655,11 @@ describe("Signage dashboard", () => {
channelId: "chan-1", channelId: "chan-1",
deviceId: null, deviceId: null,
id: "viewer-1", id: "viewer-1",
lastError: { atMs: Date.now(), code: "SLIDE_DECODE_FAILED", detail: null }, lastError: {
atMs: Date.now(),
code: "SLIDE_DECODE_FAILED",
detail: null,
},
lastSeenAtMs: Date.now() - 4000, lastSeenAtMs: Date.now() - 4000,
name: "Lobby north", name: "Lobby north",
offlineReady: true, offlineReady: true,
@@ -494,13 +677,19 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context)); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"), [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Displays",
),
); );
expect(container.textContent).toContain("Online"); expect(container.textContent).toContain("Online");
expect(container.textContent).toContain("12 cached"); expect(container.textContent).toContain("12 cached");
const display = container.querySelector("[data-testid='signage-display-viewer-1']"); const display = container.querySelector(
expect(display?.closest(".signage-channel")?.textContent).toContain("Default"); "[data-testid='signage-display-viewer-1']",
);
expect(display?.closest(".signage-channel")?.textContent).toContain(
"Default",
);
// A sanitized code, never a stack or a message a browser happened to have. // A sanitized code, never a stack or a message a browser happened to have.
expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
}); });
@@ -508,7 +697,9 @@ describe("Signage dashboard", () => {
it("explains display registration and deletes a channel group only after confirmation", async () => { it("explains display registration and deletes a channel group only after confirmation", async () => {
const secondGroup = { const secondGroup = {
...GROUP, ...GROUP,
channels: [{ id: "chan-2", isDefault: true, key: "default", name: "Default" }], channels: [
{ id: "chan-2", isDefault: true, key: "default", name: "Default" },
],
id: "group-2", id: "group-2",
name: "Foyer", name: "Foyer",
playback: { ...GROUP.playback, groupId: "group-2" }, playback: { ...GROUP.playback, groupId: "group-2" },
@@ -518,15 +709,31 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"], permissions: ["signage.view", "signage.displays.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{ {
method: "GET", method: "GET",
path: "channel-groups", path: "channel-groups",
respond: () => jsonResponse({ groups: currentGroups }), respond: () => jsonResponse({ groups: currentGroups }),
}, },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, {
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, method: "GET",
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) }, path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "viewers",
respond: () => jsonResponse({ viewers: [] }),
},
{ {
method: "DELETE", method: "DELETE",
path: "channel-groups/group-1", path: "channel-groups/group-1",
@@ -559,7 +766,9 @@ describe("Signage dashboard", () => {
expect(container.textContent).toContain("Audience channels"); expect(container.textContent).toContain("Audience channels");
expect(container.textContent).toContain("Channel name"); expect(container.textContent).toContain("Channel name");
expect(container.textContent).toContain("Audience key"); expect(container.textContent).toContain("Audience key");
expect(container.textContent).toContain("Advanced: lowercase ID sent to content providers"); expect(container.textContent).toContain(
"Advanced: lowercase ID sent to content providers",
);
await click(buttonNamed("Delete group")); await click(buttonNamed("Delete group"));
expect(deleted).toEqual([]); expect(deleted).toEqual([]);
expect(container.textContent).toContain("Displays assigned here will move"); expect(container.textContent).toContain("Displays assigned here will move");
@@ -572,11 +781,31 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.assets.manage"], permissions: ["signage.view", "signage.assets.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "images",
respond: () => jsonResponse({ images: [] }),
},
{ {
method: "POST", method: "POST",
path: /^images\?/u, path: /^images\?/u,
@@ -596,7 +825,9 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context)); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"), [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Images",
),
); );
const input = container.querySelector( const input = container.querySelector(
@@ -626,21 +857,39 @@ describe("Signage dashboard", () => {
const uploads = container.querySelector("[data-testid='signage-uploads']"); const uploads = container.querySelector("[data-testid='signage-uploads']");
expect(uploads?.textContent).toContain("done"); expect(uploads?.textContent).toContain("done");
expect(uploads?.textContent).toContain("failed"); expect(uploads?.textContent).toContain("failed");
expect(uploads?.textContent).toContain("The uploaded file is not a decodable image."); expect(uploads?.textContent).toContain(
"The uploaded file is not a decodable image.",
);
}); });
it("stays on screen when one panel's data cannot be loaded", async () => { it("stays on screen when one panel's data cannot be loaded", async () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view"], permissions: ["signage.view"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{ {
method: "GET", method: "GET",
path: "playback/group-1", path: "playback/group-1",
respond: () => respond: () =>
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500), errorResponse(
"SIGNAGE_OPERATION_FAILED",
"The operation could not be completed.",
500,
),
}, },
], ],
}); });
@@ -648,7 +897,9 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context)); await render(dashboard(host.context));
expect(container.textContent).toContain("Signage could not load this"); expect(container.textContent).toContain("Signage could not load this");
expect(container.textContent).toContain("The operation could not be completed."); expect(container.textContent).toContain(
"The operation could not be completed.",
);
// The rest of the dashboard is still usable: one failing panel is not a failed screen. // The rest of the dashboard is still usable: one failing panel is not a failed screen.
expect(container.querySelectorAll("[role='tab']").length).toBe(5); expect(container.querySelectorAll("[role='tab']").length).toBe(5);
}); });
+30 -6
View File
@@ -37,7 +37,11 @@ export function jsonResponse(body: unknown, status = 200): Response {
}); });
} }
export function errorResponse(code: string, message: string, status = 400): Response { export function errorResponse(
code: string,
message: string,
status = 400,
): Response {
return jsonResponse({ code, message }, status); return jsonResponse({ code, message }, status);
} }
@@ -45,6 +49,7 @@ export interface FakeHostOptions {
readonly permissions: readonly string[]; readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[]; readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[]; readonly contributions?: readonly ModuleContributionDescriptor[];
readonly displayUrl?: string;
readonly principal?: ModuleUiHostContext["principal"]; readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: ( readonly mount?: (
options: MountModuleContributionOptions, options: MountModuleContributionOptions,
@@ -69,15 +74,24 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
return route.respond({ init, path: normalized }); return route.respond({ init, path: normalized });
} }
} }
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404); return errorResponse(
"SIGNAGE_NOT_FOUND",
"The requested resource was not found.",
404,
);
}, },
}; };
const contributions: ModuleUiContributions = { const contributions: ModuleUiContributions = {
list: (capabilityId) => list: (capabilityId) =>
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [], capabilityId === "signage.slide-renderer"
? (options.contributions ?? [])
: [],
async mount(mountOptions) { async mount(mountOptions) {
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input }); mounted.push({
contributionId: mountOptions.contributionId,
input: mountOptions.input,
});
if (options.mount) { if (options.mount) {
return options.mount(mountOptions); return options.mount(mountOptions);
} }
@@ -95,8 +109,13 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
context: { context: {
api, api,
contributions, contributions,
display: {
url: options.displayUrl ?? "https://api.example.test/display/signage",
},
moduleId: "signage", moduleId: "signage",
permissions: { has: (permission) => options.permissions.includes(permission) }, permissions: {
has: (permission) => options.permissions.includes(permission),
},
principal: options.principal ?? { kind: "anonymous" }, principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) }, realtime: { subscribe: () => ({ close: () => undefined }) },
}, },
@@ -118,5 +137,10 @@ export const FAKE_SETTINGS = Object.freeze({
}, },
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",
timezone: "America/Chicago", timezone: "America/Chicago",
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 }, uploads: {
batchMaxFiles: 20,
concurrency: 3,
maxBytes: 26_214_400,
maxPixels: 40_000_000,
},
}); });