12 Commits
Author SHA1 Message Date
The_River 5e418bf941 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
2026-10-01 21:36:31 +00:00
The_River a68d7ebaee Update README.md 2026-10-01 21:32:21 +00:00
ekklesia 1bf89a3942 feat: align Signage with platform display and design 2026-09-30 18:21:42 -05:00
The_River ac2b8ded93 Merge pull request 'Fix/signage playback and displays' (#2) from fix/signage-playback-and-displays into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/2
2026-09-30 02:01:22 +00:00
ekklesia af0f0adadc test: align signage release expectations 2026-09-29 20:50:02 -05:00
ekklesia 36c1a72363 feat: make display setup actionable 2026-09-29 20:49:51 -05:00
ekklesia 7999582460 fix: preserve valid timeline slot positions 2026-09-29 20:49:39 -05:00
ekklesia 218e05e71e chore: release signage 0.1.1 2026-09-29 19:33:56 -05:00
ekklesia afac1bf8e7 chore: release signage 0.1.1 2026-09-29 19:27:03 -05:00
The_River 49d056f744 Merge pull request 'Fix/signage stabilization' (#1) from fix/signage-stabilization into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/1
2026-09-29 23:57:32 +00:00
ekklesia aa8cb1c96f fix: complete display and channel group management 2026-09-29 18:15:26 -05:00
ekklesia 5760059ee3 fix: make timeline activation durable and visible 2026-09-29 18:15:08 -05:00
24 changed files with 3400 additions and 1923 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
## Status
Version 0.1.0 is feature-complete for its declared scope and unreleased. What exists today:
Version 0.1.1 is feature-complete for its declared scope and unreleased. What exists today:
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
one logical storage bucket.
+1 -1
View File
@@ -3,7 +3,7 @@
"id": "signage",
"name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.0",
"version": "0.1.1",
"compatibility": {
"platform": "^0.1.0",
"moduleSdk": "^0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/signage",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
+1 -1
View File
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
*/
export default defineModuleUi({
id: "signage",
version: "0.1.0",
version: "0.1.1",
styles: displayStyles,
mount(container, context) {
const root = createRoot(container);
+10 -1
View File
@@ -91,11 +91,20 @@ export function registerPlaybackRoutes(
const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database);
const groups = await store.listChannelGroups();
const group = groups.find((candidate) => candidate.group.id === groupId);
if (!group) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
if (groups.length <= 1) {
// An installation always has somewhere for a viewer to land.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
}
await store.deleteChannelGroup(groupId);
const fallbackGroup = groups.find((candidate) => candidate.group.id !== groupId);
const fallbackChannel = fallbackGroup?.channels.find((channel) => channel.isDefault);
if (!fallbackChannel) {
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-fallback-channel" });
}
await store.deleteChannelGroup(groupId, fallbackChannel.id);
return new Response(null, { status: 204 });
});
},
+2 -2
View File
@@ -126,7 +126,7 @@ export function createSignageModuleRuntime(
const definition = defineModuleRuntime({
id: "signage",
version: "0.1.0",
version: "0.1.1",
register({ capabilities, config, http, realtime }) {
const settings = readSignageSettings(config);
@@ -188,7 +188,7 @@ export function createSignageModuleRuntime(
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
return Response.json({
crossfadeMs: current.crossfadeMs,
moduleVersion: "0.1.0",
moduleVersion: "0.1.1",
nativeImageDurationMs: current.nativeImageDurationMs,
nativePresentation: current.nativePresentation,
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
+16 -2
View File
@@ -7,6 +7,7 @@ import {
playbackStates,
selections,
selectionVariants,
viewers,
} from "../../schema/tables.js";
import {
type NewStoredSelection,
@@ -176,8 +177,21 @@ export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackSt
});
},
async deleteChannelGroup(groupId: string): Promise<void> {
await database.delete(channelGroups).where(eq(channelGroups.id, groupId));
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
await database.transaction(async (transaction) => {
const removedChannels = await transaction
.select({ id: channels.id })
.from(channels)
.where(eq(channels.groupId, groupId));
const removedChannelIds = removedChannels.map((channel) => channel.id);
if (removedChannelIds.length > 0) {
await transaction
.update(viewers)
.set({ channelId: fallbackChannelId, updatedAt: new Date() })
.where(inArray(viewers.channelId, removedChannelIds));
}
await transaction.delete(channelGroups).where(eq(channelGroups.id, groupId));
});
},
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
+6 -5
View File
@@ -112,20 +112,21 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
/**
* Rewrites `position` for one timeline from an explicit order.
*
* Positions are written in two passes, through a temporary negative range, because the unique
* Positions are written in two passes, through a temporary high range, because the unique
* `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while
* another still holds 1 would collide. Negative positions are outside every real value, so the
* intermediate state cannot be mistaken for an order.
* another still holds 1 would collide. Temporary positions sit beyond the final order while
* remaining inside the database's non-negative constraint.
*/
async function renumber(
transaction: ModuleDatabase,
timelineId: string,
orderedSlotIds: readonly string[],
): Promise<void> {
const temporaryOffset = orderedSlotIds.length + 1;
for (const [index, slotId] of orderedSlotIds.entries()) {
await transaction
.update(timelineSlots)
.set({ position: -1 - index })
.set({ position: temporaryOffset + index })
.where(eq(timelineSlots.id, slotId));
}
for (const [index, slotId] of orderedSlotIds.entries()) {
@@ -175,7 +176,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
position === undefined
? existing.length
: Math.max(0, Math.min(position, existing.length));
await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000));
await transaction.insert(timelineSlots).values(slotValues(slot, existing.length + 1));
const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
await renumber(transaction, slot.timelineId, next);
});
+3 -1
View File
@@ -351,7 +351,9 @@ export interface SignagePlaybackStore {
readonly channelId: string;
readonly channelName: string;
}): Promise<void>;
deleteChannelGroup(groupId: string): Promise<void>;
/** Deletes a group and moves every assigned viewer to the supplied fallback channel atomically,
* so removing a group can never leave a display unassigned. */
deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void>;
addChannel(channel: StoredChannel): Promise<void>;
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
removeChannel(channelId: string): Promise<void>;
+67 -13
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";
@@ -24,8 +24,10 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
*/
export function SignageDashboard({
context,
copyText,
}: {
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.
@@ -33,7 +35,10 @@ export function SignageDashboard({
// 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
? api.get<SettingsResponse>("settings")
: Promise.resolve(undefined),
[mayView],
);
const groups = useResource(
@@ -47,7 +52,9 @@ export function SignageDashboard({
const [groupId, setGroupId] = useState<string | undefined>(undefined);
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 mayManageDisplays = context.permissions.has("signage.displays.manage");
@@ -61,16 +68,31 @@ export function SignageDashboard({
}
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 =
groups.state.status === "ready"
? groups.state.value?.groups.find(
(candidate) => candidate.id === selectedGroupId,
)
: undefined;
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}
@@ -93,18 +115,28 @@ export function SignageDashboard({
{
content: (
<TimelinesPanel
{...(selectedGroup
? {
activeGroupName: selectedGroup.name,
activeTimelineId:
selectedGroup.playback.activeTimelineId,
}
: {})}
api={api}
canManage={mayManageTimelines}
onActivate={
mayControl && selectedGroupId !== undefined
? (timelineId) => {
void api
.send("POST", `playback/${selectedGroupId}/activate`, {
? async (timelineId) => {
await api.send(
"POST",
`playback/${selectedGroupId}/activate`,
{
timelineId,
when: "now",
})
.then(() => setTab("playback"))
.catch(() => setTab("playback"));
},
);
groups.reload();
setTab("playback");
}
: undefined
}
@@ -116,23 +148,45 @@ export function SignageDashboard({
},
{
content: (
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
<ImagesPanel
api={api}
canManage={mayManageAssets}
settings={loaded}
/>
),
id: "images",
label: "Images",
},
{
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
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} />,
content: (
<ProvidersPanel
api={api}
context={context}
settings={loaded}
/>
),
id: "providers",
label: "Providers",
},
]}
/>
</div>
),
{ label: "Loading Signage…", onRetry: settings.reload },
)}
+14 -2
View File
@@ -15,11 +15,23 @@ import ownStyles from "./styles.css";
*/
export default defineModuleUi({
id: "signage",
version: "0.1.0",
version: "0.1.1",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) {
const root = createRoot(container);
root.render(<SignageDashboard context={context} />);
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();
},
});
+430 -156
View File
@@ -1,36 +1,120 @@
import {
Badge,
Button,
EmptyState,
Field,
InlineNotice,
Panel,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
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,
}: {
readonly canManage: boolean;
readonly groups: readonly ChannelGroupView[];
readonly nowMs: number;
readonly onChannelChange: (channelId: string) => void;
readonly onNameChange: (name: string) => void;
readonly viewer: ViewerHealthView;
}): ReactElement {
return (
<div
className="signage-display-row"
data-testid={`signage-display-${viewer.id}`}
>
<div className="signage-display-row__identity">
{canManage ? (
<TextInput
aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name}
onBlur={(event) =>
event.target.value !== viewer.name &&
onNameChange(event.target.value)
}
/>
) : (
<strong>{viewer.name}</strong>
)}
<OnlineBadge online={viewer.online} />
</div>
<div className="signage-display-row__assignment">
{canManage ? (
<Field label="Audience channel">
{(control) => (
<Select
{...control}
aria-label={`Channel for ${viewer.name}`}
onChange={(event) => onChannelChange(event.target.value)}
value={viewer.channelId ?? ""}
>
{groups.map((group) => (
<optgroup key={group.id} label={group.name}>
{group.channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</optgroup>
))}
</Select>
)}
</Field>
) : undefined}
</div>
<div className="signage-display-row__health">
<span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span>
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
{viewer.offlineReady
? `${viewer.cachedAssetCount} cached`
: "Not offline ready"}
</Badge>
{viewer.lastError === null ? undefined : (
<span
className="signage-display-row__error"
title={viewer.lastError.detail ?? undefined}
>
{viewer.lastError.code}
</span>
)}
</div>
</div>
);
}
/**
* Channel groups, audience channels, and the display roster.
*
* A church starts with one group and one default channel. `in-person` and `online` are created here,
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
* understand and ignore (ADR 0004).
* Channel groups, audience channels, and displays, presented in the same hierarchy they use at run
* time: one group clock, one audience variant per channel, then the screens assigned to it.
*/
export function DisplaysPanel({
api,
canManage,
copyText,
displaySetupUrl,
onGroupsChanged,
}: {
readonly api: SignageApi;
readonly canManage: boolean;
readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string | undefined;
readonly onGroupsChanged?: () => void;
}): ReactElement {
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
@@ -43,31 +127,117 @@ export function DisplaysPanel({
const edit = useAction(() => {
groups.reload();
viewers.reload();
onGroupsChanged?.();
});
const viewerEdit = useAction(viewers.reload);
const [groupName, setGroupName] = useState("");
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
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: "",
});
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
useTick(5000);
const nowMs = Date.now();
const readyViewers =
viewers.state.status === "ready" ? viewers.state.value.viewers : [];
const channels =
groups.state.status === "ready"
? groups.state.value.groups.flatMap((group) =>
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
)
: [];
const updateViewer = (
viewerId: string,
changes: Record<string, string>,
): void => {
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
};
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">
<div className="signage-inline-form signage-new-group">
<Field label="New channel group">
{(control) => (
<TextInput
@@ -82,80 +252,239 @@ export function DisplaysPanel({
disabled={edit.pending || groupName.trim().length === 0}
onClick={() =>
edit.run(async () => {
await api.send("POST", "channel-groups", { name: groupName.trim() });
await api.send("POST", "channel-groups", {
name: groupName.trim(),
});
setGroupName("");
})
}
variant="primary"
>
Create
Create group
</Button>
</div>
) : undefined
}
description="A group is one clock. An audience channel is one rendering of it."
title="Channel groups"
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) => (
(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) => (
<div className="signage-group" key={group.id}>
<div className="signage-group__header">
<h4 className="signage-editor__subtitle">{group.name}</h4>
<Badge tone="info">{group.playback.transport}</Badge>
<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>
<ul className="ek-list">
{group.channels.map((channel) => (
<li className="ek-list__item signage-row" key={channel.id}>
<span className="signage-row__name">{channel.name}</span>
<Badge tone={channel.isDefault ? "success" : "neutral"}>
{channel.key}
<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}`))
edit.run(() =>
api.send(
"DELETE",
`channels/${channel.id}`,
),
)
}
variant="danger"
>
Remove
Remove channel
</Button>
) : undefined}
</li>
))}
</ul>
</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-inline-form" data-testid="signage-add-channel">
<Field hint="Lowercase, e.g. online" label="Audience key">
<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({
setChannelDraft((current) => ({
groupId: group.id,
key: event.target.value,
name: channelDraft.name,
})
key:
current.groupId === group.id
? current.key
: "",
name: event.target.value,
}))
}
placeholder="Online audience"
value={
channelDraft.groupId === group.id
? channelDraft.name
: ""
}
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
/>
)}
</Field>
<Field label="Channel name">
<Field
hint="Advanced: lowercase ID sent to content providers, e.g. online."
label="Audience key"
>
{(control) => (
<TextInput
{...control}
maxLength={64}
onChange={(event) =>
setChannelDraft({
setChannelDraft((current) => ({
groupId: group.id,
key: channelDraft.key,
name: event.target.value,
})
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
: ""
}
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
/>
)}
</Field>
@@ -163,125 +492,70 @@ export function DisplaysPanel({
disabled={
edit.pending ||
channelDraft.groupId !== group.id ||
channelDraft.key.trim() === "" ||
!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`, {
await api.send(
"POST",
`channel-groups/${group.id}/channels`,
{
key: channelDraft.key.trim(),
name: channelDraft.name.trim(),
},
);
setChannelDraft({
groupId: "",
key: "",
name: "",
});
setChannelDraft({ groupId: "", key: "", name: "" });
})
}
variant="primary"
>
Add audience channel
Add channel
</Button>
</div>
) : undefined}
</div>
</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 },
)}
</Panel>
<Panel
description="Every screen Signage has seen, and what it last reported."
title="Displays"
>
{renderLoadable(
viewers.state,
(value) =>
value.viewers.length === 0 ? (
<EmptyState
description="Open a Signage display on a screen and it will register itself here."
title="No displays yet"
/>
) : (
<Table label="Display health">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
<TableCell header>State</TableCell>
<TableCell header>Channel</TableCell>
<TableCell header>Last seen</TableCell>
<TableCell header>Offline ready</TableCell>
<TableCell header>Most recent error</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.viewers.map((viewer) => (
<TableRow key={viewer.id}>
<TableCell>
{canManage ? (
<TextInput
aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name}
onBlur={(event) =>
event.target.value !== viewer.name &&
edit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
name: event.target.value,
}),
)
}
/>
) : (
viewer.name
)}
</TableCell>
<TableCell>
<OnlineBadge online={viewer.online} />
</TableCell>
<TableCell>
{canManage ? (
<Select
aria-label={`Channel for ${viewer.name}`}
onChange={(event) =>
edit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
channelId: event.target.value,
}),
)
}
value={viewer.channelId ?? ""}
>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{`${channel.groupName} · ${channel.name}`}
</option>
))}
</Select>
) : (
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
)}
</TableCell>
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
<TableCell>
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
</Badge>
</TableCell>
<TableCell>
{viewer.lastError === null ? (
"—"
) : (
<span title={viewer.lastError.detail ?? undefined}>
{viewer.lastError.code}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
{ label: "Loading displays…", onRetry: viewers.reload },
)}
{renderLoadable(viewers.state, () => null, {
label: "Loading displays…",
onRetry: viewers.reload,
})}
<FailureNote failure={edit.failure} />
<FailureNote failure={viewerEdit.failure} />
</Panel>
</div>
);
+105 -22
View File
@@ -1,5 +1,13 @@
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";
@@ -10,7 +18,12 @@ import type {
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.
@@ -70,8 +83,19 @@ export function PlaybackPanel({
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
}
function timelineLabel(timelineId: string): string {
if (timelines.state.status !== "ready") {
return timelineId;
}
return (
<div className="signage-columns">
timelines.state.value.timelines.find(
(timeline) => timeline.id === timelineId,
)?.name ?? timelineId
);
}
return (
<div className="signage-columns signage-playback-layout">
<Panel
actions={renderLoadable(
groups.state,
@@ -106,8 +130,16 @@ export function PlaybackPanel({
value === undefined ? (
<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">
{value.snapshot.activeTimelineId === null ? undefined : (
<Badge tone="success">
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
</Badge>
)}
<Badge
tone={
value.snapshot.transport === "playing"
@@ -119,7 +151,9 @@ export function PlaybackPanel({
>
{value.snapshot.transport}
</Badge>
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
{value.snapshot.blackout ? (
<Badge tone="danger">Blackout</Badge>
) : undefined}
{value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge>
) : undefined}
@@ -137,7 +171,12 @@ export function PlaybackPanel({
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>
<dd>{value.current.providerId}</dd>
<dt>Item</dt>
@@ -153,13 +192,26 @@ export function PlaybackPanel({
: formatDuration(value.snapshot.remainingMs)}
</dd>
<dt>Started</dt>
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
<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")}>
<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" ? (
@@ -171,17 +223,27 @@ export function PlaybackPanel({
Resume
</Button>
) : (
<Button disabled={command.pending} onClick={() => run("pause")}>
<Button
disabled={command.pending}
onClick={() => run("pause")}
>
Pause
</Button>
)}
<Button disabled={command.pending} onClick={() => run("next")}>
<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"}
onClick={() =>
run("blackout", { on: !value.snapshot.blackout })
}
variant={
value.snapshot.blackout ? "primary" : "secondary"
}
>
{value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button>
@@ -193,10 +255,13 @@ export function PlaybackPanel({
Release hold
</Button>
)}
<Button disabled={command.pending} onClick={() => run("reload")}>
<Button
disabled={command.pending}
onClick={() => run("reload")}
>
Reload content
</Button>
</div>
</ActionBar>
) : undefined}
{canControl ? (
@@ -209,12 +274,17 @@ export function PlaybackPanel({
{(control) => (
<Select
{...control}
onChange={(event) => setActivateId(event.target.value)}
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}>
<option
key={timeline.id}
value={timeline.id}
>
{timeline.name}
</option>
))}
@@ -224,7 +294,10 @@ export function PlaybackPanel({
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "now" })
run("activate", {
timelineId: activateId,
when: "now",
})
}
variant="primary"
>
@@ -233,7 +306,10 @@ export function PlaybackPanel({
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "next-boundary" })
run("activate", {
timelineId: activateId,
when: "next-boundary",
})
}
>
At next slot
@@ -247,11 +323,18 @@ export function PlaybackPanel({
{value.history.length === 0 ? undefined : (
<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">
{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>
<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>
+23 -8
View File
@@ -44,12 +44,17 @@ export function ProvidersPanel({
[],
);
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 (
<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"
>
{renderLoadable(
@@ -73,20 +78,27 @@ export function ProvidersPanel({
</TableHead>
<TableBody>
{value.providers.map((provider) => {
const rendererPublished = published.has(provider.renderer.contributionId);
const isNative = provider.renderer.contributionId === "signage.native-image";
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__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>
<Badge tone="info">Built in</Badge>
) : rendererPublished ? (
<Badge tone="success">{provider.renderer.contributionId}</Badge>
<Badge tone="success">
{provider.renderer.contributionId}
</Badge>
) : (
<Badge tone="warning">Renderer not published</Badge>
)}
@@ -101,7 +113,10 @@ export function ProvidersPanel({
)}
</TableCell>
<TableCell>
{formatInstant(provider.lastSuccessAt, settings.timezone)}
{formatInstant(
provider.lastSuccessAt,
settings.timezone,
)}
</TableCell>
</TableRow>
);
+117 -28
View File
@@ -25,7 +25,12 @@ import type {
TimelineDetail,
TimelineSummary,
} 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";
@@ -42,11 +47,15 @@ export function TimelinesPanel({
settings,
canManage,
onActivate,
activeTimelineId,
activeGroupName,
}: {
readonly api: SignageApi;
readonly settings: SettingsResponse;
readonly canManage: boolean;
readonly onActivate: ((timelineId: string) => void) | undefined;
readonly onActivate: ((timelineId: string) => Promise<void>) | undefined;
readonly activeTimelineId?: string | null;
readonly activeGroupName?: string;
}): ReactElement {
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
@@ -62,6 +71,7 @@ export function TimelinesPanel({
setNewName("");
timelines.reload();
});
const activation = useAction();
return (
<div className="signage-columns">
@@ -82,7 +92,9 @@ export function TimelinesPanel({
<Button
disabled={create.pending || newName.trim().length === 0}
onClick={() =>
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
create.run(() =>
api.send("POST", "timelines", { name: newName.trim() }),
)
}
variant="primary"
>
@@ -114,20 +126,50 @@ export function TimelinesPanel({
</TableHead>
<TableBody>
{value.timelines.map((timeline) => (
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
<TableRow
key={timeline.id}
selected={timeline.id === selectedId}
>
<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 ? (
<Badge tone="success">
{activeGroupName
? `Active in ${activeGroupName}`
: "Active"}
</Badge>
) : undefined}
</div>
</TableCell>
<TableCell>
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
{timeline.eligibleFrom === null &&
timeline.eligibleUntil === null
? "Always"
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
</TableCell>
<TableCell>{timeline.revision}</TableCell>
<TableCell>
{onActivate === undefined ? undefined : (
<Button onClick={() => onActivate(timeline.id)} variant="primary">
Activate
<Button
disabled={
activation.pending ||
timeline.id === activeTimelineId
}
onClick={() =>
activation.run(() => onActivate(timeline.id))
}
variant="primary"
>
{timeline.id === activeTimelineId
? "Active"
: "Activate"}
</Button>
)}
</TableCell>
@@ -139,6 +181,7 @@ export function TimelinesPanel({
{ label: "Loading timelines…", onRetry: timelines.reload },
)}
<FailureNote failure={create.failure} />
<FailureNote failure={activation.failure} />
</Panel>
{selectedId === undefined ? undefined : (
@@ -148,7 +191,11 @@ export function TimelinesPanel({
key={selectedId}
onChanged={timelines.reload}
onClose={() => setSelectedId(undefined)}
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
providers={
providers.state.status === "ready"
? providers.state.value.providers
: []
}
settings={settings}
timelineId={selectedId}
/>
@@ -178,13 +225,18 @@ function TimelineEditor({
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
[timelineId],
);
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("images"),
[],
);
const edit = useAction(() => {
detail.reload();
onChanged();
});
const [providerId, setProviderId] = useState("");
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>(
[],
);
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
function loadProvider(nextProviderId: string): void {
@@ -221,7 +273,10 @@ function TimelineEditor({
<h3 className="signage-editor__title">{value.timeline.name}</h3>
{canManage ? (
<div className="signage-editor__add" data-testid="signage-add-source">
<div
className="signage-editor__add"
data-testid="signage-add-source"
>
<Field
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
label="Content provider"
@@ -234,7 +289,10 @@ function TimelineEditor({
>
<option value="">Choose a provider…</option>
{providers.map((provider) => (
<option key={provider.providerId} value={provider.providerId}>
<option
key={provider.providerId}
value={provider.providerId}
>
{provider.providerId}
</option>
))}
@@ -290,9 +348,13 @@ function TimelineEditor({
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, {
api.send(
"POST",
`timelines/${timelineId}/slots`,
{
sourceId: source.id,
}),
},
),
)
}
>
@@ -302,7 +364,10 @@ function TimelineEditor({
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
api.send(
"DELETE",
`timelines/${timelineId}/sources/${source.id}`,
),
)
}
variant="danger"
@@ -338,7 +403,8 @@ function TimelineEditor({
(candidate) => candidate.id === slot.sourceId,
);
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(
(candidate) => candidate.id === slot.itemId,
)
@@ -349,7 +415,8 @@ function TimelineEditor({
<TableCell>
{slot.kind === "provider-source"
? (source?.label ?? "Removed source")
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
: (image?.displayName ??
`${slot.providerId} · ${slot.itemId}`)}
</TableCell>
<TableCell>
{slot.durationOverrideMs === null
@@ -363,9 +430,13 @@ function TimelineEditor({
label={slot.enabled ? "On" : "Off"}
onChange={(event) =>
edit.run(() =>
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
api.send(
"PATCH",
`timelines/${timelineId}/slots/${slot.id}`,
{
enabled: event.target.checked,
}),
},
),
)
}
/>
@@ -377,29 +448,44 @@ function TimelineEditor({
disabled={index === 0 || edit.pending}
onClick={() =>
edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, {
api.send(
"PUT",
`timelines/${timelineId}/slot-order`,
{
slotIds: swap(
value.slots.map((candidate) => candidate.id),
value.slots.map(
(candidate) => candidate.id,
),
index,
index - 1,
),
}),
},
),
)
}
>
Up
</Button>
<Button
disabled={index === value.slots.length - 1 || edit.pending}
disabled={
index === value.slots.length - 1 ||
edit.pending
}
onClick={() =>
edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, {
api.send(
"PUT",
`timelines/${timelineId}/slot-order`,
{
slotIds: swap(
value.slots.map((candidate) => candidate.id),
value.slots.map(
(candidate) => candidate.id,
),
index,
index + 1,
),
}),
},
),
)
}
>
@@ -409,7 +495,10 @@ function TimelineEditor({
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
api.send(
"DELETE",
`timelines/${timelineId}/slots/${slot.id}`,
),
)
}
variant="danger"
+335 -9
View File
@@ -28,7 +28,7 @@
}
:host {
--signage-gap: var(--ekklesia-spacing-lg);
--signage-gap: var(--ekklesia-spacing-xl);
--signage-gap-tight: var(--ekklesia-spacing-sm);
--signage-panel-radius: var(--ekklesia-radius-lg);
--signage-thumbnail-width: 220px;
@@ -69,10 +69,19 @@
.signage-desktop {
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 {
display: grid;
gap: var(--signage-gap);
@@ -167,7 +176,10 @@
.signage-image-grid {
display: grid;
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;
margin: 0;
padding: 0;
@@ -177,13 +189,13 @@
/* 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. */
align-content: start;
background: var(--ekklesia-background-canvas);
background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--ekklesia-spacing-xs);
gap: var(--ekklesia-spacing-sm);
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
@@ -285,6 +297,19 @@
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.
* ------------------------------------------------------------------------------------------- */
@@ -311,6 +336,19 @@
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 {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
@@ -326,6 +364,23 @@
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.
* ------------------------------------------------------------------------------------------- */
@@ -335,20 +390,291 @@
gap: var(--signage-gap);
}
.signage-group {
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
.signage-pairing-steps {
display: grid;
gap: var(--signage-gap-tight);
grid-template-columns: repeat(3, minmax(0, 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.signage-pairing-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
.signage-pairing-actions a {
text-decoration: none;
}
.signage-copy-status {
color: var(--ekklesia-text-secondary);
display: block;
font-size: var(--ekklesia-typography-size-sm);
min-height: 1.25em;
margin-block-start: var(--ekklesia-spacing-xs);
}
.signage-pairing-steps li {
align-items: center;
background: var(--ekklesia-accent-subtle);
border-radius: var(--ekklesia-radius-md);
display: flex;
gap: var(--signage-gap-tight);
min-width: 0;
padding: var(--ekklesia-spacing-md);
}
.signage-pairing-steps__number {
align-items: center;
background: var(--ekklesia-accent-base);
border-radius: var(--ekklesia-radius-pill);
color: var(--ekklesia-accent-foreground);
display: inline-flex;
flex: 0 0 var(--ekklesia-control-height-sm);
font-weight: var(--ekklesia-typography-weight-bold);
height: var(--ekklesia-control-height-sm);
justify-content: center;
}
.signage-display-empty {
align-items: baseline;
color: var(--ekklesia-text-secondary);
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-xs) var(--signage-gap-tight);
margin-block-start: var(--signage-gap-tight);
}
.signage-display-empty strong {
color: var(--ekklesia-text-primary);
}
.signage-new-group > .ek-field {
min-width: 16rem;
}
.signage-group {
background: var(--ekklesia-background-inset);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--signage-panel-radius);
display: grid;
gap: var(--signage-gap);
padding: var(--ekklesia-spacing-lg);
}
.signage-group__header {
align-items: center;
display: flex;
gap: var(--signage-gap-tight);
}
.signage-group__header > .ek-button {
margin-inline-start: auto;
}
.signage-group__identity {
display: grid;
gap: var(--ekklesia-spacing-2xs);
}
.signage-group__identity h3,
.signage-audiences__heading h4,
.signage-channel__header h5,
.signage-channel-form__heading h5 {
margin: 0;
}
.signage-hierarchy-label {
color: var(--ekklesia-text-muted);
font-size: var(--ekklesia-typography-size-xs);
font-weight: var(--ekklesia-typography-weight-medium);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.signage-confirm {
align-items: center;
background: var(--ekklesia-status-danger-background);
border-radius: var(--ekklesia-radius-md);
color: var(--ekklesia-text-danger);
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
padding: var(--ekklesia-spacing-sm);
}
.signage-confirm p {
flex: 1 1 30rem;
margin: 0;
}
.signage-audiences {
border-block-start: var(--ekklesia-border-width) solid
var(--ekklesia-border-divider);
display: grid;
gap: var(--signage-gap);
padding-block-start: var(--signage-gap);
}
.signage-audiences__heading {
align-items: end;
display: flex;
gap: var(--signage-gap);
justify-content: space-between;
}
.signage-audiences__heading > div {
display: grid;
gap: var(--ekklesia-spacing-2xs);
}
.signage-audiences__heading > span {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-channel-list {
display: grid;
gap: var(--signage-gap-tight);
}
.signage-channel {
background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-inline-start: var(--ekklesia-spacing-2xs) solid
var(--ekklesia-accent-base);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--signage-gap-tight);
margin-inline-start: var(--ekklesia-spacing-md);
padding: var(--ekklesia-spacing-md);
}
.signage-channel__header {
align-items: start;
display: flex;
gap: var(--signage-gap-tight);
justify-content: space-between;
}
.signage-channel__header > div {
display: grid;
gap: var(--ekklesia-spacing-xs);
}
.signage-channel__meta {
align-items: center;
color: var(--ekklesia-text-secondary);
display: flex;
flex-wrap: wrap;
font-size: var(--ekklesia-typography-size-sm);
gap: var(--ekklesia-spacing-xs);
}
.signage-channel__meta code,
.signage-pairing-steps code {
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-sm);
color: var(--ekklesia-text-primary);
padding: var(--ekklesia-spacing-2xs) var(--ekklesia-spacing-xs);
}
.signage-channel__displays {
border-block-start: var(--ekklesia-border-width) solid
var(--ekklesia-border-divider);
display: grid;
gap: var(--signage-gap-tight);
padding-block-start: var(--signage-gap-tight);
}
.signage-channel__empty {
color: var(--ekklesia-text-muted);
font-size: var(--ekklesia-typography-size-sm);
margin: 0;
}
.signage-display-row {
align-items: end;
background: var(--ekklesia-background-canvas);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--signage-gap-tight);
grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(
14rem,
1.2fr
);
padding: var(--ekklesia-spacing-sm);
}
.signage-display-row__identity,
.signage-display-row__health {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-xs);
}
.signage-display-row__identity .ek-control,
.signage-display-row__assignment .ek-field {
width: 100%;
}
.signage-display-row__health {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-display-row__error {
color: var(--ekklesia-text-danger);
}
.signage-channel-form,
.signage-unassigned {
background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--signage-gap-tight);
margin-inline-start: var(--ekklesia-spacing-md);
padding: var(--ekklesia-spacing-md);
}
.signage-channel-form {
align-items: start;
grid-template-columns: minmax(16rem, 2fr) minmax(14rem, 1fr);
}
.signage-channel-form__heading,
.signage-channel-form > .ek-button {
grid-column: 1 / -1;
}
.signage-channel-form__heading {
display: grid;
gap: var(--ekklesia-spacing-2xs);
}
.signage-channel-form__heading span {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-channel-form > .ek-button {
justify-self: start;
}
@media (max-width: 1200px) {
.signage-pairing-steps,
.signage-channel-form,
.signage-display-row {
grid-template-columns: 1fr;
}
}
.signage-provider__id {
font-weight: var(--ekklesia-typography-weight-medium);
}
+478 -65
View File
@@ -3,7 +3,12 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
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.
@@ -14,10 +19,28 @@ import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./su
*/
// 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 root: Root;
const DISPLAY_SETUP_URL = "https://api.example.test/display/signage";
const copiedText: string[] = [];
function dashboard(
context: Parameters<typeof SignageDashboard>[0]["context"],
): React.ReactElement {
return (
<SignageDashboard
context={context}
copyText={(value) => {
copiedText.push(value);
return Promise.resolve();
}}
/>
);
}
async function render(element: React.ReactElement): Promise<void> {
await act(async () => {
@@ -70,7 +93,9 @@ function buttonNamed(label: string): HTMLButtonElement | undefined {
}
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",
name: "Main",
playback: {
@@ -120,7 +145,18 @@ const PLAYBACK_DETAIL = {
snapshot: GROUP.playback,
};
const TIMELINE = {
description: null,
eligibleFrom: null,
eligibleUntil: null,
id: "timeline-1",
name: "Sunday morning",
revision: 3,
updatedAt: "2026-08-30T15:00:00.000Z",
};
beforeEach(() => {
copiedText.length = 0;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@@ -139,9 +175,21 @@ describe("Signage dashboard", () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{
method: "GET",
path: "settings",
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",
@@ -158,12 +206,14 @@ describe("Signage dashboard", () => {
],
});
await render(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
expect(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-current']")?.textContent,
).toContain("welcome");
expect(
container.querySelector("[data-testid='signage-remaining']")?.textContent,
).toBe("4s");
await click(buttonNamed("Pause"));
await click(buttonNamed("Next"));
@@ -180,45 +230,200 @@ describe("Signage dashboard", () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", 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",
path: "settings",
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(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
// 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(container.querySelector("[data-testid='signage-controls']")).toBeNull();
expect(
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 () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"],
routes: [
{
method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [TIMELINE] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
],
});
await render(dashboard(host.context));
expect(container.textContent).toContain("Active: Sunday morning");
await click(
[...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Timelines",
),
);
expect(container.textContent).toContain("Active in Main");
expect(buttonNamed("Active")?.disabled).toBe(true);
});
it("keeps a failed activation visible instead of pretending it succeeded", async () => {
const inactiveGroup = {
...GROUP,
playback: {
...GROUP.playback,
activeTimelineId: null,
currentSelectionId: null,
},
};
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"],
routes: [
{
method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [inactiveGroup] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [TIMELINE] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
{
method: "POST",
path: "playback/group-1/activate",
respond: () =>
errorResponse(
"SIGNAGE_CONFLICT",
"The request conflicts with the current state.",
409,
),
},
],
});
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Timelines",
),
);
await click(buttonNamed("Activate"));
expect(container.textContent).toContain("Sunday morning");
expect(container.querySelector("[role='alert']")?.textContent).toContain(
"The request conflicts with the current state.",
);
});
it("shows Signage's own sanitized reason when a save is refused", async () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"],
routes: [
{ method: "GET", path: "settings", 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", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{
method: "GET",
path: "settings",
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",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
{
method: "POST",
path: "timelines",
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400),
respond: () =>
errorResponse(
"SIGNAGE_INVALID_REQUEST",
"The request is not valid.",
400,
),
},
],
});
await render(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines",
),
);
const input = container.querySelector("input[type='text']") as HTMLInputElement;
const input = container.querySelector(
"input[type='text']",
) as HTMLInputElement;
await type(input, "Sunday morning");
await click(buttonNamed("Create"));
@@ -232,10 +437,26 @@ describe("Signage dashboard", () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"],
routes: [
{ method: "GET", path: "settings", 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",
path: "settings",
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",
path: "timelines",
@@ -275,7 +496,7 @@ describe("Signage dashboard", () => {
],
});
await render(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines",
@@ -283,7 +504,9 @@ describe("Signage dashboard", () => {
);
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");
});
@@ -294,10 +517,26 @@ describe("Signage dashboard", () => {
contributions: [],
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", 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",
path: "settings",
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",
path: "providers",
@@ -325,7 +564,7 @@ describe("Signage dashboard", () => {
],
});
await render(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers",
@@ -340,15 +579,35 @@ describe("Signage dashboard", () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", 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", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{
method: "GET",
path: "settings",
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",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
],
});
await render(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers",
@@ -356,17 +615,35 @@ describe("Signage dashboard", () => {
);
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 () => {
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: [
{ method: "GET", path: "settings", 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",
path: "settings",
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",
path: "viewers",
@@ -378,7 +655,11 @@ describe("Signage dashboard", () => {
channelId: "chan-1",
deviceId: null,
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,
name: "Lobby north",
offlineReady: true,
@@ -394,27 +675,137 @@ describe("Signage dashboard", () => {
],
});
await render(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
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("12 cached");
const display = container.querySelector(
"[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.
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
});
it("explains display registration and deletes a channel group only after confirmation", async () => {
const secondGroup = {
...GROUP,
channels: [
{ id: "chan-2", isDefault: true, key: "default", name: "Default" },
],
id: "group-2",
name: "Foyer",
playback: { ...GROUP.playback, groupId: "group-2" },
};
let currentGroups = [GROUP, secondGroup];
const deleted: string[] = [];
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: [
{
method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: currentGroups }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "viewers",
respond: () => jsonResponse({ viewers: [] }),
},
{
method: "DELETE",
path: "channel-groups/group-1",
respond: ({ path }) => {
deleted.push(path);
currentGroups = [secondGroup];
return new Response(null, { status: 204 });
},
},
],
});
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Displays",
),
);
const setupLink = [...container.querySelectorAll("a")].find(
(candidate) => candidate.textContent?.trim() === "Open display setup",
);
expect(setupLink?.href).toBe(DISPLAY_SETUP_URL);
expect(setupLink?.target).toBe("_blank");
expect(setupLink?.rel).toContain("noopener");
await click(buttonNamed("Copy URL"));
expect(copiedText).toEqual([DISPLAY_SETUP_URL]);
expect(container.textContent).toContain("Display setup URL copied.");
expect(container.textContent).toContain("Channel groups and displays");
expect(container.textContent).toContain("Audience channels");
expect(container.textContent).toContain("Channel name");
expect(container.textContent).toContain("Audience key");
expect(container.textContent).toContain(
"Advanced: lowercase ID sent to content providers",
);
await click(buttonNamed("Delete group"));
expect(deleted).toEqual([]);
expect(container.textContent).toContain("Displays assigned here will move");
await click(buttonNamed("Confirm delete"));
expect(deleted).toEqual(["channel-groups/group-1"]);
});
it("uploads one request per file and reports each one's own outcome", async () => {
const uploaded: string[] = [];
const host = createFakeHost({
permissions: ["signage.view", "signage.assets.manage"],
routes: [
{ method: "GET", path: "settings", 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", path: "images", respond: () => jsonResponse({ images: [] }) },
{
method: "GET",
path: "settings",
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",
path: "images",
respond: () => jsonResponse({ images: [] }),
},
{
method: "POST",
path: /^images\?/u,
@@ -432,9 +823,11 @@ describe("Signage dashboard", () => {
],
});
await render(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Images",
),
);
const input = container.querySelector(
@@ -464,29 +857,49 @@ describe("Signage dashboard", () => {
const uploads = container.querySelector("[data-testid='signage-uploads']");
expect(uploads?.textContent).toContain("done");
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 () => {
const host = createFakeHost({
permissions: ["signage.view"],
routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
{
method: "GET",
path: "settings",
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: () =>
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500),
errorResponse(
"SIGNAGE_OPERATION_FAILED",
"The operation could not be completed.",
500,
),
},
],
});
await render(<SignageDashboard context={host.context} />);
await render(dashboard(host.context));
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.
expect(container.querySelectorAll("[role='tab']").length).toBe(5);
});
+31 -7
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);
}
@@ -45,6 +49,7 @@ export interface FakeHostOptions {
readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[];
readonly displayUrl?: string;
readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: (
options: MountModuleContributionOptions,
@@ -69,15 +74,24 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
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 = {
list: (capabilityId) =>
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
capabilityId === "signage.slide-renderer"
? (options.contributions ?? [])
: [],
async mount(mountOptions) {
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input });
mounted.push({
contributionId: mountOptions.contributionId,
input: mountOptions.input,
});
if (options.mount) {
return options.mount(mountOptions);
}
@@ -95,8 +109,13 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
context: {
api,
contributions,
display: {
url: options.displayUrl ?? "https://api.example.test/display/signage",
},
moduleId: "signage",
permissions: { has: (permission) => options.permissions.includes(permission) },
permissions: {
has: (permission) => options.permissions.includes(permission),
},
principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) },
},
@@ -108,7 +127,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
/** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000,
moduleVersion: "0.1.0",
moduleVersion: "0.1.1",
nativeImageDurationMs: 7000,
nativePresentation: {
backgroundColor: "#000000",
@@ -118,5 +137,10 @@ export const FAKE_SETTINGS = Object.freeze({
},
rendererInputVersion: "1.0.0",
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,
},
});
+95
View File
@@ -0,0 +1,95 @@
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import { describe, expect, it } from "vitest";
import { createTimelineStore } from "../src/server/store/drizzle-timelines.js";
import type { NewStoredSlot } from "../src/server/store/types.js";
const TIMELINE_ID = "00000000-0000-4000-8000-000000000001";
function slot(id: string): NewStoredSlot {
return {
durationOverrideMs: null,
eligibleFrom: null,
eligibleUntil: null,
enabled: true,
id,
itemId: "image-1",
kind: "pinned-item",
presentation: {
backgroundColor: null,
backgroundDarkened: null,
backgroundMode: null,
blurPixels: null,
foregroundShadow: null,
},
providerId: "signage.native-images",
sourceId: null,
timelineId: TIMELINE_ID,
};
}
/**
* A narrow database contract double for the production timeline store.
*
* Unlike the service-level memory store, it enforces the shipped PostgreSQL
* `signage_timeline_slots_position_check` on every insert and update. That is the boundary the
* deployed bug crossed: the old collision-avoidance algorithm used negative positions, so the
* actual database rejected a native-image slot before playback ever had content to resolve.
*/
function constraintDatabase(existing: readonly { id: string; position: number }[]): {
readonly database: ModuleDatabase;
readonly positions: number[];
} {
const positions: number[] = [];
const enforce = (values: { position?: unknown }): void => {
if (typeof values.position === "number") {
expect(values.position).toBeGreaterThanOrEqual(0);
positions.push(values.position);
}
};
const transaction = {
insert: () => ({
values: async (values: { position?: unknown }) => enforce(values),
}),
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => existing,
}),
}),
}),
update: () => ({
set: (values: { position?: unknown }) => ({
where: async () => enforce(values),
}),
}),
};
return {
database: {
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
} as unknown as ModuleDatabase,
positions,
};
}
describe("production timeline slot ordering", () => {
it("creates the first pinned native image without violating the database position constraint", async () => {
const { database, positions } = constraintDatabase([]);
await createTimelineStore(database).addSlot(slot("00000000-0000-4000-8000-000000000002"));
expect(positions).toEqual([1, 2, 0]);
});
it("reorders through a non-negative temporary range before writing the final order", async () => {
const first = "00000000-0000-4000-8000-000000000002";
const second = "00000000-0000-4000-8000-000000000003";
const { database, positions } = constraintDatabase([
{ id: first, position: 0 },
{ id: second, position: 1 },
]);
await createTimelineStore(database).reorderSlots(TIMELINE_ID, [second, first]);
expect(positions).toEqual([3, 4, 0, 1]);
});
});
+3 -3
View File
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
expect(manifest.manifestVersion).toBe(7);
expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.0");
expect(manifest.version).toBe("0.1.1");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
});
@@ -142,7 +142,7 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.0" });
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.1" });
});
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
@@ -241,7 +241,7 @@ describe("Signage server runtime", () => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
crossfadeMs: 400,
moduleVersion: "0.1.0",
moduleVersion: "0.1.1",
nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0",
+24
View File
@@ -589,6 +589,30 @@ describe("authoritative playback", () => {
expect(announcements.resolveRequests.length).toBe(callsBefore);
});
it("keeps the active timeline through a complete playback-service restart", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
await service.activate(group.group.id, timeline.timeline.id, "now");
const before = await service.snapshot(group.group.id);
service.stop();
clock += 2000;
service = createPlaybackService({
newId,
now: () => clock,
registry,
setTimer: () => undefined,
settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 },
store,
});
await service.start();
const after = await service.snapshot(group.group.id);
expect(after.activeTimelineId).toBe(timeline.timeline.id);
expect(after.currentSelectionId).toBe(before.currentSelectionId);
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
});
it("publishes a compact snapshot, with no payload or timeline in it", async () => {
const group = await seedGroup();
const timeline = await seedPattern();
+1 -1
View File
@@ -103,7 +103,7 @@ export function createMemoryPlaybackStore(
return Promise.resolve();
},
deleteChannelGroup(groupId: string): Promise<void> {
deleteChannelGroup(groupId: string, _fallbackChannelId: string): Promise<void> {
groups.delete(groupId);
playback.delete(groupId);
for (const [id, channel] of channels) {
+14
View File
@@ -46,6 +46,20 @@ export function createMemorySignageStore(
...playbackStore,
...viewerStore,
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
const removedChannelIds = new Set(
[...playbackStore.channels.values()]
.filter((channel) => channel.groupId === groupId)
.map((channel) => channel.id),
);
for (const viewer of viewerStore.viewers.values()) {
if (viewer.channelId !== null && removedChannelIds.has(viewer.channelId)) {
await viewerStore.updateViewer(viewer.id, { channelId: fallbackChannelId });
}
}
await playbackStore.deleteChannelGroup(groupId, fallbackChannelId);
},
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
references.set(
+28
View File
@@ -128,6 +128,34 @@ describe("viewers", () => {
expect(store.viewers.size).toBe(1);
});
it("moves an assigned display atomically when its channel group is deleted", async () => {
const original = (await store.listChannelGroups())[0];
if (!original) {
throw new Error("Expected the default group.");
}
const session = await openViewerSession({
at: new Date(clock),
deviceId: null,
newId,
store,
viewerKey: "kiosk-lobby-0003",
});
expect(original.channels.some((channel) => channel.id === session.channel.id)).toBe(true);
const fallbackGroupId = newId();
const fallbackChannelId = newId();
await store.createChannelGroup({
channelId: fallbackChannelId,
channelName: "Default",
groupId: fallbackGroupId,
name: "Foyer",
});
await store.deleteChannelGroup(original.group.id, fallbackChannelId);
expect(await store.findChannelGroup(original.group.id)).toBeUndefined();
expect((await store.findViewer(session.viewer.id))?.channelId).toBe(fallbackChannelId);
});
it("stores only the safe device identifier a paired principal carries", async () => {
const session = await openViewerSession({
at: new Date(clock),