Compare commits
7
Commits
218e05e71e
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e418bf941 | ||
|
|
a68d7ebaee | ||
|
|
1bf89a3942 | ||
|
|
ac2b8ded93 | ||
|
|
af0f0adadc | ||
|
|
36c1a72363 | ||
|
|
7999582460 |
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+46
-10
@@ -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,20 +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)
|
||||
? 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}
|
||||
@@ -100,7 +118,8 @@ export function SignageDashboard({
|
||||
{...(selectedGroup
|
||||
? {
|
||||
activeGroupName: selectedGroup.name,
|
||||
activeTimelineId: selectedGroup.playback.activeTimelineId,
|
||||
activeTimelineId:
|
||||
selectedGroup.playback.activeTimelineId,
|
||||
}
|
||||
: {})}
|
||||
api={api}
|
||||
@@ -108,10 +127,14 @@ export function SignageDashboard({
|
||||
onActivate={
|
||||
mayControl && selectedGroupId !== undefined
|
||||
? async (timelineId) => {
|
||||
await api.send("POST", `playback/${selectedGroupId}/activate`, {
|
||||
await api.send(
|
||||
"POST",
|
||||
`playback/${selectedGroupId}/activate`,
|
||||
{
|
||||
timelineId,
|
||||
when: "now",
|
||||
});
|
||||
},
|
||||
);
|
||||
groups.reload();
|
||||
setTab("playback");
|
||||
}
|
||||
@@ -125,7 +148,11 @@ export function SignageDashboard({
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
||||
<ImagesPanel
|
||||
api={api}
|
||||
canManage={mayManageAssets}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "images",
|
||||
label: "Images",
|
||||
@@ -135,6 +162,8 @@ export function SignageDashboard({
|
||||
<DisplaysPanel
|
||||
api={api}
|
||||
canManage={mayManageDisplays}
|
||||
copyText={copyText}
|
||||
displaySetupUrl={context.display?.url}
|
||||
onGroupsChanged={() => {
|
||||
setGroupId(undefined);
|
||||
groups.reload();
|
||||
@@ -145,12 +174,19 @@ export function SignageDashboard({
|
||||
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 },
|
||||
)}
|
||||
|
||||
+13
-1
@@ -19,7 +19,19 @@ export default defineModuleUi({
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
+392
-182
@@ -1,39 +1,119 @@
|
||||
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(
|
||||
@@ -51,30 +131,113 @@ export function DisplaysPanel({
|
||||
});
|
||||
const viewerEdit = useAction(viewers.reload);
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
|
||||
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
|
||||
@@ -89,28 +252,46 @@ 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>
|
||||
<article className="signage-group" key={group.id}>
|
||||
<header className="signage-group__header">
|
||||
<div className="signage-group__identity">
|
||||
<span className="signage-hierarchy-label">
|
||||
Channel group
|
||||
</span>
|
||||
<h3>{group.name}</h3>
|
||||
</div>
|
||||
<Badge tone="info">{group.playback.transport}</Badge>
|
||||
{canManage && value.groups.length > 1 ? (
|
||||
<Button
|
||||
@@ -121,23 +302,30 @@ export function DisplaysPanel({
|
||||
Delete group
|
||||
</Button>
|
||||
) : canManage ? (
|
||||
<Badge tone="neutral">At least one group required</Badge>
|
||||
<Badge tone="neutral">Required group</Badge>
|
||||
) : undefined}
|
||||
</div>
|
||||
</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.
|
||||
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)}>
|
||||
<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}`);
|
||||
await api.send(
|
||||
"DELETE",
|
||||
`channel-groups/${group.id}`,
|
||||
);
|
||||
setConfirmGroupId(undefined);
|
||||
})
|
||||
}
|
||||
@@ -147,32 +335,134 @@ export function DisplaysPanel({
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
<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}
|
||||
|
||||
<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">
|
||||
<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="Stable lowercase ID sent to content providers for audience-specific variants, e.g. online."
|
||||
label="Provider audience key"
|
||||
hint="Shown to administrators and when assigning displays."
|
||||
label="Channel name"
|
||||
>
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
maxLength={120}
|
||||
onChange={(event) =>
|
||||
setChannelDraft((current) => ({
|
||||
groupId: group.id,
|
||||
key:
|
||||
current.groupId === group.id
|
||||
? current.key
|
||||
: "",
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Online audience"
|
||||
value={
|
||||
channelDraft.groupId === group.id
|
||||
? channelDraft.name
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
hint="Advanced: lowercase ID sent to content providers, e.g. online."
|
||||
label="Audience key"
|
||||
>
|
||||
{(control) => (
|
||||
<TextInput
|
||||
@@ -182,32 +472,19 @@ export function DisplaysPanel({
|
||||
setChannelDraft((current) => ({
|
||||
groupId: group.id,
|
||||
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]+)*"
|
||||
placeholder="online"
|
||||
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
hint="Friendly label shown to administrators and used when assigning displays."
|
||||
label="Dashboard channel name"
|
||||
>
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
maxLength={120}
|
||||
onChange={(event) =>
|
||||
setChannelDraft((current) => ({
|
||||
groupId: group.id,
|
||||
key: current.groupId === group.id ? current.key : "",
|
||||
name: event.target.value,
|
||||
}))
|
||||
value={
|
||||
channelDraft.groupId === group.id
|
||||
? channelDraft.key
|
||||
: ""
|
||||
}
|
||||
placeholder="Online audience"
|
||||
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
@@ -221,129 +498,62 @@ export function DisplaysPanel({
|
||||
}
|
||||
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="Pair a screen through Ekklesia, then name it and choose which audience channel it shows."
|
||||
title="Displays"
|
||||
>
|
||||
{canManage ? (
|
||||
<div className="signage-registration-guide">
|
||||
<h4 className="signage-editor__subtitle">Register a display</h4>
|
||||
<ol>
|
||||
<li>Open the Ekklesia path /display/signage on the screen.</li>
|
||||
<li>Enter its six-digit code under Display devices in the Dashboard sidebar.</li>
|
||||
<li>When it appears below, give it a clear name and assign its channel.</li>
|
||||
</ol>
|
||||
</div>
|
||||
) : undefined}
|
||||
{renderLoadable(
|
||||
viewers.state,
|
||||
(value) =>
|
||||
value.viewers.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Open /display/signage on the screen, approve its code under Display devices, and it will appear here automatically."
|
||||
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 &&
|
||||
viewerEdit.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) =>
|
||||
viewerEdit.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>
|
||||
|
||||
@@ -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.
|
||||
@@ -75,13 +88,14 @@ export function PlaybackPanel({
|
||||
return timelineId;
|
||||
}
|
||||
return (
|
||||
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
|
||||
timelineId
|
||||
timelines.state.value.timelines.find(
|
||||
(timeline) => timeline.id === timelineId,
|
||||
)?.name ?? timelineId
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<div className="signage-columns signage-playback-layout">
|
||||
<Panel
|
||||
actions={renderLoadable(
|
||||
groups.state,
|
||||
@@ -116,7 +130,10 @@ 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">
|
||||
@@ -134,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}
|
||||
@@ -152,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>
|
||||
@@ -168,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" ? (
|
||||
@@ -186,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>
|
||||
@@ -208,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 ? (
|
||||
@@ -224,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>
|
||||
))}
|
||||
@@ -239,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"
|
||||
>
|
||||
@@ -248,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
|
||||
@@ -262,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -87,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"
|
||||
>
|
||||
@@ -119,17 +126,31 @@ 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"}
|
||||
{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>
|
||||
@@ -137,11 +158,18 @@ export function TimelinesPanel({
|
||||
<TableCell>
|
||||
{onActivate === undefined ? undefined : (
|
||||
<Button
|
||||
disabled={activation.pending || timeline.id === activeTimelineId}
|
||||
onClick={() => activation.run(() => onActivate(timeline.id))}
|
||||
disabled={
|
||||
activation.pending ||
|
||||
timeline.id === activeTimelineId
|
||||
}
|
||||
onClick={() =>
|
||||
activation.run(() => onActivate(timeline.id))
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
{timeline.id === activeTimelineId ? "Active" : "Activate"}
|
||||
{timeline.id === activeTimelineId
|
||||
? "Active"
|
||||
: "Activate"}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -163,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}
|
||||
/>
|
||||
@@ -193,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 {
|
||||
@@ -236,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"
|
||||
@@ -249,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>
|
||||
))}
|
||||
@@ -305,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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -317,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"
|
||||
@@ -353,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,
|
||||
)
|
||||
@@ -364,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
|
||||
@@ -378,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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -392,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,
|
||||
),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -424,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"
|
||||
|
||||
+308
-16
@@ -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,24 +390,112 @@
|
||||
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:first-of-type {
|
||||
.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);
|
||||
@@ -369,18 +512,167 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-registration-guide {
|
||||
background: var(--ekklesia-background-inset);
|
||||
.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(--ekklesia-spacing-xs);
|
||||
margin-block-end: var(--signage-gap);
|
||||
gap: var(--signage-gap-tight);
|
||||
margin-inline-start: var(--ekklesia-spacing-md);
|
||||
padding: var(--ekklesia-spacing-md);
|
||||
}
|
||||
|
||||
.signage-registration-guide ol {
|
||||
.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;
|
||||
padding-inline-start: var(--ekklesia-spacing-xl);
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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: {
|
||||
@@ -131,6 +156,7 @@ const TIMELINE = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
copiedText.length = 0;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
@@ -149,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",
|
||||
@@ -168,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"));
|
||||
@@ -190,37 +230,73 @@ 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: "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: [] }) },
|
||||
{
|
||||
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));
|
||||
expect(container.textContent).toContain("Active: Sunday morning");
|
||||
await click(
|
||||
[...container.querySelectorAll("[role='tab']")].find(
|
||||
@@ -235,12 +311,20 @@ describe("Signage dashboard", () => {
|
||||
it("keeps a failed activation visible instead of pretending it succeeded", async () => {
|
||||
const inactiveGroup = {
|
||||
...GROUP,
|
||||
playback: { ...GROUP.playback, activeTimelineId: null, currentSelectionId: null },
|
||||
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: "settings",
|
||||
respond: () => jsonResponse(FAKE_SETTINGS),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "channel-groups",
|
||||
@@ -251,18 +335,30 @@ describe("Signage dashboard", () => {
|
||||
path: "timelines",
|
||||
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",
|
||||
path: "playback/group-1/activate",
|
||||
respond: () =>
|
||||
errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409),
|
||||
errorResponse(
|
||||
"SIGNAGE_CONFLICT",
|
||||
"The request conflicts with the current state.",
|
||||
409,
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await render(<SignageDashboard context={host.context} />);
|
||||
await render(dashboard(host.context));
|
||||
await click(
|
||||
[...container.querySelectorAll("[role='tab']")].find(
|
||||
(candidate) => candidate.textContent === "Timelines",
|
||||
@@ -280,27 +376,54 @@ 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: "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"));
|
||||
|
||||
@@ -314,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",
|
||||
@@ -357,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",
|
||||
@@ -365,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");
|
||||
});
|
||||
|
||||
@@ -376,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",
|
||||
@@ -407,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",
|
||||
@@ -422,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",
|
||||
@@ -438,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",
|
||||
@@ -460,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,
|
||||
@@ -476,13 +675,21 @@ 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");
|
||||
});
|
||||
@@ -490,7 +697,9 @@ describe("Signage dashboard", () => {
|
||||
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" }],
|
||||
channels: [
|
||||
{ id: "chan-2", isDefault: true, key: "default", name: "Default" },
|
||||
],
|
||||
id: "group-2",
|
||||
name: "Foyer",
|
||||
playback: { ...GROUP.playback, groupId: "group-2" },
|
||||
@@ -500,15 +709,31 @@ describe("Signage dashboard", () => {
|
||||
const host = createFakeHost({
|
||||
permissions: ["signage.view", "signage.displays.manage"],
|
||||
routes: [
|
||||
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||
{
|
||||
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: "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",
|
||||
@@ -521,15 +746,29 @@ describe("Signage dashboard", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await render(<SignageDashboard context={host.context} />);
|
||||
await render(dashboard(host.context));
|
||||
await click(
|
||||
[...container.querySelectorAll("[role='tab']")].find(
|
||||
(candidate) => candidate.textContent === "Displays",
|
||||
),
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("/display/signage");
|
||||
expect(container.textContent).toContain("Provider audience key");
|
||||
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");
|
||||
@@ -542,11 +781,31 @@ describe("Signage dashboard", () => {
|
||||
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,
|
||||
@@ -564,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(
|
||||
@@ -596,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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user