7 Commits
13 changed files with 3272 additions and 2164 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
## Status ## 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 - The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
one logical storage bucket. one logical storage bucket.
+6 -5
View File
@@ -112,20 +112,21 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
/** /**
* Rewrites `position` for one timeline from an explicit order. * 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 * `(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 * another still holds 1 would collide. Temporary positions sit beyond the final order while
* intermediate state cannot be mistaken for an order. * remaining inside the database's non-negative constraint.
*/ */
async function renumber( async function renumber(
transaction: ModuleDatabase, transaction: ModuleDatabase,
timelineId: string, timelineId: string,
orderedSlotIds: readonly string[], orderedSlotIds: readonly string[],
): Promise<void> { ): Promise<void> {
const temporaryOffset = orderedSlotIds.length + 1;
for (const [index, slotId] of orderedSlotIds.entries()) { for (const [index, slotId] of orderedSlotIds.entries()) {
await transaction await transaction
.update(timelineSlots) .update(timelineSlots)
.set({ position: -1 - index }) .set({ position: temporaryOffset + index })
.where(eq(timelineSlots.id, slotId)); .where(eq(timelineSlots.id, slotId));
} }
for (const [index, slotId] of orderedSlotIds.entries()) { for (const [index, slotId] of orderedSlotIds.entries()) {
@@ -175,7 +176,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
position === undefined position === undefined
? existing.length ? existing.length
: Math.max(0, Math.min(position, 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)]; const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
await renumber(transaction, slot.timelineId, next); await renumber(transaction, slot.timelineId, next);
}); });
+46 -10
View File
@@ -1,5 +1,5 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui"; import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
import { type ReactElement, useMemo, useState } from "react"; import { type ReactElement, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js"; import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js"; import { createSignageApi } from "./api.js";
@@ -24,8 +24,10 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
*/ */
export function SignageDashboard({ export function SignageDashboard({
context, context,
copyText,
}: { }: {
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>;
}): ReactElement { }): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]); const api = useMemo(() => createSignageApi(context.api), [context.api]);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing. // Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
@@ -33,7 +35,10 @@ export function SignageDashboard({
// request it will certainly refuse is noise in a log rather than security. // request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view"); const mayView = context.permissions.has("signage.view");
const settings = useResource( const settings = useResource(
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)), () =>
mayView
? api.get<SettingsResponse>("settings")
: Promise.resolve(undefined),
[mayView], [mayView],
); );
const groups = useResource( const groups = useResource(
@@ -47,7 +52,9 @@ export function SignageDashboard({
const [groupId, setGroupId] = useState<string | undefined>(undefined); const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage"); const mayManageAssets = context.permissions.has("signage.assets.manage");
const mayManageTimelines = context.permissions.has("signage.timelines.manage"); const mayManageTimelines = context.permissions.has(
"signage.timelines.manage",
);
const mayControl = context.permissions.has("signage.playback.control"); const mayControl = context.permissions.has("signage.playback.control");
const mayManageDisplays = context.permissions.has("signage.displays.manage"); const mayManageDisplays = context.permissions.has("signage.displays.manage");
@@ -61,20 +68,31 @@ export function SignageDashboard({
} }
const selectedGroupId = const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined); groupId ??
(groups.state.status === "ready"
? groups.state.value?.groups[0]?.id
: undefined);
const selectedGroup = const selectedGroup =
groups.state.status === "ready" groups.state.status === "ready"
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId) ? groups.state.value?.groups.find(
(candidate) => candidate.id === selectedGroupId,
)
: undefined; : undefined;
return ( return (
<> <>
<EkklesiaUiStyles /> <EkklesiaUiStyles />
<DesktopOnly> <DesktopOnly>
<PageHeader
description="Plan visual content, operate playback, and keep every screen connected."
eyebrow="Media"
title="Signage"
/>
{renderLoadable( {renderLoadable(
settings.state, settings.state,
(loaded) => (loaded) =>
loaded === undefined ? undefined : ( loaded === undefined ? undefined : (
<div className="signage-workspace">
<Tabs <Tabs
label="Signage sections" label="Signage sections"
onSelect={setTab} onSelect={setTab}
@@ -100,7 +118,8 @@ export function SignageDashboard({
{...(selectedGroup {...(selectedGroup
? { ? {
activeGroupName: selectedGroup.name, activeGroupName: selectedGroup.name,
activeTimelineId: selectedGroup.playback.activeTimelineId, activeTimelineId:
selectedGroup.playback.activeTimelineId,
} }
: {})} : {})}
api={api} api={api}
@@ -108,10 +127,14 @@ export function SignageDashboard({
onActivate={ onActivate={
mayControl && selectedGroupId !== undefined mayControl && selectedGroupId !== undefined
? async (timelineId) => { ? async (timelineId) => {
await api.send("POST", `playback/${selectedGroupId}/activate`, { await api.send(
"POST",
`playback/${selectedGroupId}/activate`,
{
timelineId, timelineId,
when: "now", when: "now",
}); },
);
groups.reload(); groups.reload();
setTab("playback"); setTab("playback");
} }
@@ -125,7 +148,11 @@ export function SignageDashboard({
}, },
{ {
content: ( content: (
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} /> <ImagesPanel
api={api}
canManage={mayManageAssets}
settings={loaded}
/>
), ),
id: "images", id: "images",
label: "Images", label: "Images",
@@ -135,6 +162,8 @@ export function SignageDashboard({
<DisplaysPanel <DisplaysPanel
api={api} api={api}
canManage={mayManageDisplays} canManage={mayManageDisplays}
copyText={copyText}
displaySetupUrl={context.display?.url}
onGroupsChanged={() => { onGroupsChanged={() => {
setGroupId(undefined); setGroupId(undefined);
groups.reload(); groups.reload();
@@ -145,12 +174,19 @@ export function SignageDashboard({
label: "Displays", label: "Displays",
}, },
{ {
content: <ProvidersPanel api={api} context={context} settings={loaded} />, content: (
<ProvidersPanel
api={api}
context={context}
settings={loaded}
/>
),
id: "providers", id: "providers",
label: "Providers", label: "Providers",
}, },
]} ]}
/> />
</div>
), ),
{ label: "Loading Signage…", onRetry: settings.reload }, { label: "Loading Signage…", onRetry: settings.reload },
)} )}
+13 -1
View File
@@ -19,7 +19,19 @@ export default defineModuleUi({
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
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(); return () => root.unmount();
}, },
}); });
+392 -182
View File
@@ -1,39 +1,119 @@
import { import {
Badge, Badge,
Button, Button,
EmptyState,
Field, Field,
InlineNotice,
Panel, Panel,
Select, Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextInput, TextInput,
} from "@ekklesia/ui"; } from "@ekklesia/ui";
import { type ReactElement, useState } from "react"; import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js"; import { useAction, useResource, useTick } from "../hooks.js";
import type { ChannelGroupView, ViewerHealthView } from "../types.js"; import type { ChannelGroupView, ViewerHealthView } from "../types.js";
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js"; import {
FailureNote,
formatSince,
OnlineBadge,
renderLoadable,
} from "./shared.js";
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
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. * 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.
* 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).
*/ */
export function DisplaysPanel({ export function DisplaysPanel({
api, api,
canManage, canManage,
copyText,
displaySetupUrl,
onGroupsChanged, onGroupsChanged,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly canManage: boolean; readonly canManage: boolean;
readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string | undefined;
readonly onGroupsChanged?: () => void; readonly onGroupsChanged?: () => void;
}): ReactElement { }): ReactElement {
const groups = useResource( const groups = useResource(
@@ -51,30 +131,113 @@ export function DisplaysPanel({
}); });
const viewerEdit = useAction(viewers.reload); const viewerEdit = useAction(viewers.reload);
const [groupName, setGroupName] = useState(""); const [groupName, setGroupName] = useState("");
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined); const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">(
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ "idle",
);
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(
undefined,
);
const [channelDraft, setChannelDraft] = useState<{
groupId: string;
key: string;
name: string;
}>({
groupId: "", groupId: "",
key: "", key: "",
name: "", 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); useTick(5000);
const nowMs = Date.now(); const nowMs = Date.now();
const readyViewers =
viewers.state.status === "ready" ? viewers.state.value.viewers : [];
const channels = const updateViewer = (
groups.state.status === "ready" viewerId: string,
? groups.state.value.groups.flatMap((group) => changes: Record<string, string>,
group.channels.map((channel) => ({ ...channel, groupName: group.name })), ): void => {
) viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
: []; };
return ( return (
<div className="signage-columns"> <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 <Panel
actions={ actions={
canManage ? ( canManage ? (
<div className="signage-inline-form"> <div className="signage-inline-form signage-new-group">
<Field label="New channel group"> <Field label="New channel group">
{(control) => ( {(control) => (
<TextInput <TextInput
@@ -89,28 +252,46 @@ export function DisplaysPanel({
disabled={edit.pending || groupName.trim().length === 0} disabled={edit.pending || groupName.trim().length === 0}
onClick={() => onClick={() =>
edit.run(async () => { edit.run(async () => {
await api.send("POST", "channel-groups", { name: groupName.trim() }); await api.send("POST", "channel-groups", {
name: groupName.trim(),
});
setGroupName(""); setGroupName("");
}) })
} }
variant="primary" variant="primary"
> >
Create Create group
</Button> </Button>
</div> </div>
) : undefined ) : undefined
} }
description="A group is one clock. An audience channel is one rendering of it." description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
title="Channel groups" title="Channel groups and displays"
> >
{renderLoadable( {renderLoadable(
groups.state, 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"> <div className="signage-groups" data-testid="signage-groups">
{value.groups.map((group) => ( {value.groups.map((group) => (
<div className="signage-group" key={group.id}> <article className="signage-group" key={group.id}>
<div className="signage-group__header"> <header className="signage-group__header">
<h4 className="signage-editor__subtitle">{group.name}</h4> <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> <Badge tone="info">{group.playback.transport}</Badge>
{canManage && value.groups.length > 1 ? ( {canManage && value.groups.length > 1 ? (
<Button <Button
@@ -121,23 +302,30 @@ export function DisplaysPanel({
Delete group Delete group
</Button> </Button>
) : canManage ? ( ) : canManage ? (
<Badge tone="neutral">At least one group required</Badge> <Badge tone="neutral">Required group</Badge>
) : undefined} ) : undefined}
</div> </header>
{confirmGroupId === group.id ? ( {confirmGroupId === group.id ? (
<div className="signage-confirm" role="alert"> <div className="signage-confirm" role="alert">
<p> <p>
Delete {group.name}? Its playback history will be removed. Displays assigned Delete {group.name}? Its playback history will be
here will move to the default channel of another group. removed. Displays assigned here will move to the
default channel of another group.
</p> </p>
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}> <Button
disabled={edit.pending}
onClick={() => setConfirmGroupId(undefined)}
>
Cancel Cancel
</Button> </Button>
<Button <Button
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(async () => { edit.run(async () => {
await api.send("DELETE", `channel-groups/${group.id}`); await api.send(
"DELETE",
`channel-groups/${group.id}`,
);
setConfirmGroupId(undefined); setConfirmGroupId(undefined);
}) })
} }
@@ -147,32 +335,134 @@ export function DisplaysPanel({
</Button> </Button>
</div> </div>
) : undefined} ) : undefined}
<ul className="ek-list">
{group.channels.map((channel) => ( <section className="signage-audiences">
<li className="ek-list__item signage-row" key={channel.id}> <div className="signage-audiences__heading">
<span className="signage-row__name">{channel.name}</span> <div>
<Badge tone={channel.isDefault ? "success" : "neutral"}> <span className="signage-hierarchy-label">
{channel.key} 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> </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 ? ( {canManage && !channel.isDefault ? (
<Button <Button
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(() => api.send("DELETE", `channels/${channel.id}`)) edit.run(() =>
api.send(
"DELETE",
`channels/${channel.id}`,
),
)
} }
variant="danger" variant="danger"
> >
Remove Remove channel
</Button> </Button>
) : undefined} ) : undefined}
</li> </header>
))} <div className="signage-channel__displays">
</ul> <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 ? ( {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 <Field
hint="Stable lowercase ID sent to content providers for audience-specific variants, e.g. online." hint="Shown to administrators and when assigning displays."
label="Provider audience key" 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) => ( {(control) => (
<TextInput <TextInput
@@ -182,32 +472,19 @@ export function DisplaysPanel({
setChannelDraft((current) => ({ setChannelDraft((current) => ({
groupId: group.id, groupId: group.id,
key: event.target.value, key: event.target.value,
name: current.groupId === group.id ? current.name : "", name:
current.groupId === group.id
? current.name
: "",
})) }))
} }
pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*" pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
placeholder="online" placeholder="online"
value={channelDraft.groupId === group.id ? channelDraft.key : ""} value={
/> channelDraft.groupId === group.id
)} ? channelDraft.key
</Field> : ""
<Field
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,
}))
} }
placeholder="Online audience"
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
/> />
)} )}
</Field> </Field>
@@ -221,129 +498,62 @@ export function DisplaysPanel({
} }
onClick={() => onClick={() =>
edit.run(async () => { edit.run(async () => {
await api.send("POST", `channel-groups/${group.id}/channels`, { await api.send(
"POST",
`channel-groups/${group.id}/channels`,
{
key: channelDraft.key.trim(), key: channelDraft.key.trim(),
name: channelDraft.name.trim(), name: channelDraft.name.trim(),
},
);
setChannelDraft({
groupId: "",
key: "",
name: "",
}); });
setChannelDraft({ groupId: "", key: "", name: "" });
}) })
} }
variant="primary"
> >
Add audience channel Add channel
</Button> </Button>
</div> </div>
) : undefined} ) : 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> </div>
), );
},
{ label: "Loading channel groups…", onRetry: groups.reload }, { label: "Loading channel groups…", onRetry: groups.reload },
)} )}
</Panel> {renderLoadable(viewers.state, () => null, {
label: "Loading displays…",
<Panel onRetry: viewers.reload,
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 },
)}
<FailureNote failure={edit.failure} /> <FailureNote failure={edit.failure} />
<FailureNote failure={viewerEdit.failure} /> <FailureNote failure={viewerEdit.failure} />
</Panel> </Panel>
+92 -24
View File
@@ -1,5 +1,13 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui"; import {
ActionBar,
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react"; import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js"; import { useAction, useResource, useTick } from "../hooks.js";
@@ -10,7 +18,12 @@ import type {
TimelineSummary, TimelineSummary,
} from "../types.js"; } from "../types.js";
import { PreviewPanel } from "./PreviewPanel.js"; import { PreviewPanel } from "./PreviewPanel.js";
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js"; import {
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
/** /**
* Live transport for one channel group. * Live transport for one channel group.
@@ -75,13 +88,14 @@ export function PlaybackPanel({
return timelineId; return timelineId;
} }
return ( return (
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ?? timelines.state.value.timelines.find(
timelineId (timeline) => timeline.id === timelineId,
)?.name ?? timelineId
); );
} }
return ( return (
<div className="signage-columns"> <div className="signage-columns signage-playback-layout">
<Panel <Panel
actions={renderLoadable( actions={renderLoadable(
groups.state, groups.state,
@@ -116,7 +130,10 @@ export function PlaybackPanel({
value === undefined ? ( value === undefined ? (
<EmptyState title="No channel group selected" /> <EmptyState title="No channel group selected" />
) : ( ) : (
<div className="signage-playback" data-testid="signage-playback"> <div
className="signage-playback"
data-testid="signage-playback"
>
<div className="signage-playback__status"> <div className="signage-playback__status">
{value.snapshot.activeTimelineId === null ? undefined : ( {value.snapshot.activeTimelineId === null ? undefined : (
<Badge tone="success"> <Badge tone="success">
@@ -134,7 +151,9 @@ export function PlaybackPanel({
> >
{value.snapshot.transport} {value.snapshot.transport}
</Badge> </Badge>
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined} {value.snapshot.blackout ? (
<Badge tone="danger">Blackout</Badge>
) : undefined}
{value.current?.stale === true ? ( {value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge> <Badge tone="warning">Stale</Badge>
) : undefined} ) : undefined}
@@ -152,7 +171,12 @@ export function PlaybackPanel({
title="Nothing on screen" title="Nothing on screen"
/> />
) : ( ) : (
<dl className="signage-playback__current" data-testid="signage-current"> <section className="signage-playback__now">
<h3>Now showing</h3>
<dl
className="signage-playback__current"
data-testid="signage-current"
>
<dt>Provider</dt> <dt>Provider</dt>
<dd>{value.current.providerId}</dd> <dd>{value.current.providerId}</dd>
<dt>Item</dt> <dt>Item</dt>
@@ -168,13 +192,26 @@ export function PlaybackPanel({
: formatDuration(value.snapshot.remainingMs)} : formatDuration(value.snapshot.remainingMs)}
</dd> </dd>
<dt>Started</dt> <dt>Started</dt>
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd> <dd>
{formatInstant(
value.current.startedAtMs,
settings.timezone,
)}
</dd>
</dl> </dl>
</section>
)} )}
{canControl ? ( {canControl ? (
<div className="signage-playback__controls" data-testid="signage-controls"> <ActionBar
<Button disabled={command.pending} onClick={() => run("previous")}> className="signage-playback__controls"
data-testid="signage-controls"
label="Playback controls"
>
<Button
disabled={command.pending}
onClick={() => run("previous")}
>
Previous Previous
</Button> </Button>
{value.snapshot.transport === "paused" ? ( {value.snapshot.transport === "paused" ? (
@@ -186,17 +223,27 @@ export function PlaybackPanel({
Resume Resume
</Button> </Button>
) : ( ) : (
<Button disabled={command.pending} onClick={() => run("pause")}> <Button
disabled={command.pending}
onClick={() => run("pause")}
>
Pause Pause
</Button> </Button>
)} )}
<Button disabled={command.pending} onClick={() => run("next")}> <Button
disabled={command.pending}
onClick={() => run("next")}
>
Next Next
</Button> </Button>
<Button <Button
disabled={command.pending} disabled={command.pending}
onClick={() => run("blackout", { on: !value.snapshot.blackout })} onClick={() =>
variant={value.snapshot.blackout ? "primary" : "secondary"} run("blackout", { on: !value.snapshot.blackout })
}
variant={
value.snapshot.blackout ? "primary" : "secondary"
}
> >
{value.snapshot.blackout ? "End blackout" : "Blackout"} {value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button> </Button>
@@ -208,10 +255,13 @@ export function PlaybackPanel({
Release hold Release hold
</Button> </Button>
)} )}
<Button disabled={command.pending} onClick={() => run("reload")}> <Button
disabled={command.pending}
onClick={() => run("reload")}
>
Reload content Reload content
</Button> </Button>
</div> </ActionBar>
) : undefined} ) : undefined}
{canControl ? ( {canControl ? (
@@ -224,12 +274,17 @@ export function PlaybackPanel({
{(control) => ( {(control) => (
<Select <Select
{...control} {...control}
onChange={(event) => setActivateId(event.target.value)} onChange={(event) =>
setActivateId(event.target.value)
}
value={activateId} value={activateId}
> >
<option value="">Choose a timeline…</option> <option value="">Choose a timeline…</option>
{loaded.timelines.map((timeline) => ( {loaded.timelines.map((timeline) => (
<option key={timeline.id} value={timeline.id}> <option
key={timeline.id}
value={timeline.id}
>
{timeline.name} {timeline.name}
</option> </option>
))} ))}
@@ -239,7 +294,10 @@ export function PlaybackPanel({
<Button <Button
disabled={command.pending || activateId === ""} disabled={command.pending || activateId === ""}
onClick={() => onClick={() =>
run("activate", { timelineId: activateId, when: "now" }) run("activate", {
timelineId: activateId,
when: "now",
})
} }
variant="primary" variant="primary"
> >
@@ -248,7 +306,10 @@ export function PlaybackPanel({
<Button <Button
disabled={command.pending || activateId === ""} disabled={command.pending || activateId === ""}
onClick={() => onClick={() =>
run("activate", { timelineId: activateId, when: "next-boundary" }) run("activate", {
timelineId: activateId,
when: "next-boundary",
})
} }
> >
At next slot At next slot
@@ -262,11 +323,18 @@ export function PlaybackPanel({
{value.history.length === 0 ? undefined : ( {value.history.length === 0 ? undefined : (
<div className="signage-playback__history"> <div className="signage-playback__history">
<h4 className="signage-editor__subtitle">Recently played</h4> <h4 className="signage-editor__subtitle">
Recently played
</h4>
<ul className="ek-list"> <ul className="ek-list">
{value.history.slice(0, 8).map((entry) => ( {value.history.slice(0, 8).map((entry) => (
<li className="ek-list__item signage-row" key={entry.id}> <li
<span className="signage-row__name">{entry.itemId}</span> className="ek-list__item signage-row"
key={entry.id}
>
<span className="signage-row__name">
{entry.itemId}
</span>
<span className="signage-playback__history-meta"> <span className="signage-playback__history-meta">
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`} {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
</span> </span>
+23 -8
View File
@@ -44,12 +44,17 @@ export function ProvidersPanel({
[], [],
); );
const published = new Set( const published = new Set(
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id), context.contributions
.list(SLIDE_RENDERER_CAPABILITY)
.map((contribution) => contribution.id),
); );
return ( return (
<Panel <Panel
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`} actions={
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
}
description="Running content sources available to timelines."
title="Content providers" title="Content providers"
> >
{renderLoadable( {renderLoadable(
@@ -73,20 +78,27 @@ export function ProvidersPanel({
</TableHead> </TableHead>
<TableBody> <TableBody>
{value.providers.map((provider) => { {value.providers.map((provider) => {
const rendererPublished = published.has(provider.renderer.contributionId); const rendererPublished = published.has(
const isNative = provider.renderer.contributionId === "signage.native-image"; provider.renderer.contributionId,
);
const isNative =
provider.renderer.contributionId === "signage.native-image";
return ( return (
<TableRow key={provider.providerId}> <TableRow key={provider.providerId}>
<TableCell> <TableCell>
<span className="signage-provider__id">{provider.providerId}</span> <span className="signage-provider__id">
{provider.providerId}
</span>
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span> <span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
</TableCell> </TableCell>
<TableCell>{provider.contractVersion}</TableCell> <TableCell>{provider.contractVersion}</TableCell>
<TableCell> <TableCell>
{isNative ? ( {isNative ? (
<Badge tone="success">Signage renders this</Badge> <Badge tone="info">Built in</Badge>
) : rendererPublished ? ( ) : rendererPublished ? (
<Badge tone="success">{provider.renderer.contributionId}</Badge> <Badge tone="success">
{provider.renderer.contributionId}
</Badge>
) : ( ) : (
<Badge tone="warning">Renderer not published</Badge> <Badge tone="warning">Renderer not published</Badge>
)} )}
@@ -101,7 +113,10 @@ export function ProvidersPanel({
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{formatInstant(provider.lastSuccessAt, settings.timezone)} {formatInstant(
provider.lastSuccessAt,
settings.timezone,
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
+103 -29
View File
@@ -25,7 +25,12 @@ import type {
TimelineDetail, TimelineDetail,
TimelineSummary, TimelineSummary,
} from "../types.js"; } from "../types.js";
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js"; import {
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
const NATIVE_PROVIDER_ID = "signage.native-images"; const NATIVE_PROVIDER_ID = "signage.native-images";
@@ -87,7 +92,9 @@ export function TimelinesPanel({
<Button <Button
disabled={create.pending || newName.trim().length === 0} disabled={create.pending || newName.trim().length === 0}
onClick={() => onClick={() =>
create.run(() => api.send("POST", "timelines", { name: newName.trim() })) create.run(() =>
api.send("POST", "timelines", { name: newName.trim() }),
)
} }
variant="primary" variant="primary"
> >
@@ -119,17 +126,31 @@ export function TimelinesPanel({
</TableHead> </TableHead>
<TableBody> <TableBody>
{value.timelines.map((timeline) => ( {value.timelines.map((timeline) => (
<TableRow key={timeline.id} selected={timeline.id === selectedId}> <TableRow
key={timeline.id}
selected={timeline.id === selectedId}
>
<TableCell> <TableCell>
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button> <div className="signage-timeline-name">
<Button
className="signage-timeline-select"
onClick={() => setSelectedId(timeline.id)}
variant="quiet"
>
{timeline.name}
</Button>
{timeline.id === activeTimelineId ? ( {timeline.id === activeTimelineId ? (
<Badge tone="success"> <Badge tone="success">
{activeGroupName ? `Active in ${activeGroupName}` : "Active"} {activeGroupName
? `Active in ${activeGroupName}`
: "Active"}
</Badge> </Badge>
) : undefined} ) : undefined}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
{timeline.eligibleFrom === null && timeline.eligibleUntil === null {timeline.eligibleFrom === null &&
timeline.eligibleUntil === null
? "Always" ? "Always"
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`} : `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
</TableCell> </TableCell>
@@ -137,11 +158,18 @@ export function TimelinesPanel({
<TableCell> <TableCell>
{onActivate === undefined ? undefined : ( {onActivate === undefined ? undefined : (
<Button <Button
disabled={activation.pending || timeline.id === activeTimelineId} disabled={
onClick={() => activation.run(() => onActivate(timeline.id))} activation.pending ||
timeline.id === activeTimelineId
}
onClick={() =>
activation.run(() => onActivate(timeline.id))
}
variant="primary" variant="primary"
> >
{timeline.id === activeTimelineId ? "Active" : "Activate"} {timeline.id === activeTimelineId
? "Active"
: "Activate"}
</Button> </Button>
)} )}
</TableCell> </TableCell>
@@ -163,7 +191,11 @@ export function TimelinesPanel({
key={selectedId} key={selectedId}
onChanged={timelines.reload} onChanged={timelines.reload}
onClose={() => setSelectedId(undefined)} onClose={() => setSelectedId(undefined)}
providers={providers.state.status === "ready" ? providers.state.value.providers : []} providers={
providers.state.status === "ready"
? providers.state.value.providers
: []
}
settings={settings} settings={settings}
timelineId={selectedId} timelineId={selectedId}
/> />
@@ -193,13 +225,18 @@ function TimelineEditor({
() => api.get<TimelineDetail>(`timelines/${timelineId}`), () => api.get<TimelineDetail>(`timelines/${timelineId}`),
[timelineId], [timelineId],
); );
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []); const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("images"),
[],
);
const edit = useAction(() => { const edit = useAction(() => {
detail.reload(); detail.reload();
onChanged(); onChanged();
}); });
const [providerId, setProviderId] = useState(""); const [providerId, setProviderId] = useState("");
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]); const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>(
[],
);
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]); const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
function loadProvider(nextProviderId: string): void { function loadProvider(nextProviderId: string): void {
@@ -236,7 +273,10 @@ function TimelineEditor({
<h3 className="signage-editor__title">{value.timeline.name}</h3> <h3 className="signage-editor__title">{value.timeline.name}</h3>
{canManage ? ( {canManage ? (
<div className="signage-editor__add" data-testid="signage-add-source"> <div
className="signage-editor__add"
data-testid="signage-add-source"
>
<Field <Field
hint="A provider's own source. Repeat its slot to give it more turns in one rotation." hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
label="Content provider" label="Content provider"
@@ -249,7 +289,10 @@ function TimelineEditor({
> >
<option value="">Choose a provider…</option> <option value="">Choose a provider…</option>
{providers.map((provider) => ( {providers.map((provider) => (
<option key={provider.providerId} value={provider.providerId}> <option
key={provider.providerId}
value={provider.providerId}
>
{provider.providerId} {provider.providerId}
</option> </option>
))} ))}
@@ -305,9 +348,13 @@ function TimelineEditor({
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, { api.send(
"POST",
`timelines/${timelineId}/slots`,
{
sourceId: source.id, sourceId: source.id,
}), },
),
) )
} }
> >
@@ -317,7 +364,10 @@ function TimelineEditor({
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`), api.send(
"DELETE",
`timelines/${timelineId}/sources/${source.id}`,
),
) )
} }
variant="danger" variant="danger"
@@ -353,7 +403,8 @@ function TimelineEditor({
(candidate) => candidate.id === slot.sourceId, (candidate) => candidate.id === slot.sourceId,
); );
const image = const image =
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready" slot.providerId === NATIVE_PROVIDER_ID &&
images.state.status === "ready"
? images.state.value.images.find( ? images.state.value.images.find(
(candidate) => candidate.id === slot.itemId, (candidate) => candidate.id === slot.itemId,
) )
@@ -364,7 +415,8 @@ function TimelineEditor({
<TableCell> <TableCell>
{slot.kind === "provider-source" {slot.kind === "provider-source"
? (source?.label ?? "Removed source") ? (source?.label ?? "Removed source")
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)} : (image?.displayName ??
`${slot.providerId} · ${slot.itemId}`)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{slot.durationOverrideMs === null {slot.durationOverrideMs === null
@@ -378,9 +430,13 @@ function TimelineEditor({
label={slot.enabled ? "On" : "Off"} label={slot.enabled ? "On" : "Off"}
onChange={(event) => onChange={(event) =>
edit.run(() => edit.run(() =>
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, { api.send(
"PATCH",
`timelines/${timelineId}/slots/${slot.id}`,
{
enabled: event.target.checked, enabled: event.target.checked,
}), },
),
) )
} }
/> />
@@ -392,29 +448,44 @@ function TimelineEditor({
disabled={index === 0 || edit.pending} disabled={index === 0 || edit.pending}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, { api.send(
"PUT",
`timelines/${timelineId}/slot-order`,
{
slotIds: swap( slotIds: swap(
value.slots.map((candidate) => candidate.id), value.slots.map(
(candidate) => candidate.id,
),
index, index,
index - 1, index - 1,
), ),
}), },
),
) )
} }
> >
Up Up
</Button> </Button>
<Button <Button
disabled={index === value.slots.length - 1 || edit.pending} disabled={
index === value.slots.length - 1 ||
edit.pending
}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, { api.send(
"PUT",
`timelines/${timelineId}/slot-order`,
{
slotIds: swap( slotIds: swap(
value.slots.map((candidate) => candidate.id), value.slots.map(
(candidate) => candidate.id,
),
index, index,
index + 1, index + 1,
), ),
}), },
),
) )
} }
> >
@@ -424,7 +495,10 @@ function TimelineEditor({
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() =>
edit.run(() => edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`), api.send(
"DELETE",
`timelines/${timelineId}/slots/${slot.id}`,
),
) )
} }
variant="danger" variant="danger"
+308 -16
View File
@@ -28,7 +28,7 @@
} }
:host { :host {
--signage-gap: var(--ekklesia-spacing-lg); --signage-gap: var(--ekklesia-spacing-xl);
--signage-gap-tight: var(--ekklesia-spacing-sm); --signage-gap-tight: var(--ekklesia-spacing-sm);
--signage-panel-radius: var(--ekklesia-radius-lg); --signage-panel-radius: var(--ekklesia-radius-lg);
--signage-thumbnail-width: 220px; --signage-thumbnail-width: 220px;
@@ -69,10 +69,19 @@
.signage-desktop { .signage-desktop {
display: grid; display: grid;
gap: var(--signage-gap); gap: var(--ekklesia-spacing-xl);
} }
} }
.signage-workspace {
display: grid;
min-width: 0;
}
.signage-workspace > .ek-tabs__list {
padding-inline: var(--ekklesia-spacing-xs);
}
.signage-columns { .signage-columns {
display: grid; display: grid;
gap: var(--signage-gap); gap: var(--signage-gap);
@@ -167,7 +176,10 @@
.signage-image-grid { .signage-image-grid {
display: grid; display: grid;
gap: var(--signage-gap); gap: var(--signage-gap);
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr)); grid-template-columns: repeat(
auto-fill,
minmax(var(--signage-thumbnail-width), 1fr)
);
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -177,13 +189,13 @@
/* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather /* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather
than spread between the thumbnail and the name. */ than spread between the thumbnail and the name. */
align-content: start; align-content: start;
background: var(--ekklesia-background-canvas); background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md); border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--ekklesia-spacing-xs); gap: var(--ekklesia-spacing-sm);
overflow: hidden; overflow: hidden;
padding: var(--ekklesia-spacing-sm); padding: var(--ekklesia-spacing-md);
} }
/* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and /* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and
@@ -285,6 +297,19 @@
gap: var(--ekklesia-spacing-2xs); gap: var(--ekklesia-spacing-2xs);
} }
.signage-timeline-name {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-sm);
}
.signage-timeline-select {
color: var(--ekklesia-text-primary);
font-weight: var(--ekklesia-typography-weight-bold);
padding-inline: var(--ekklesia-spacing-sm);
}
/* --------------------------------------------------------------------------------------------- /* ---------------------------------------------------------------------------------------------
* Playback. * Playback.
* ------------------------------------------------------------------------------------------- */ * ------------------------------------------------------------------------------------------- */
@@ -311,6 +336,19 @@
row-gap: var(--ekklesia-spacing-2xs); row-gap: var(--ekklesia-spacing-2xs);
} }
.signage-playback__now {
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--ekklesia-spacing-sm);
padding: var(--ekklesia-spacing-md);
}
.signage-playback__now h3 {
font-size: var(--ekklesia-typography-size-lg);
margin: 0;
}
.signage-playback__current dt { .signage-playback__current dt {
color: var(--ekklesia-text-secondary); color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm); font-size: var(--ekklesia-typography-size-sm);
@@ -326,6 +364,23 @@
font-size: var(--ekklesia-typography-size-sm); font-size: var(--ekklesia-typography-size-sm);
} }
.signage-playback__history {
display: grid;
gap: var(--ekklesia-spacing-sm);
}
.signage-playback__history .ek-list {
max-height: 14rem;
overflow: auto;
}
@media (min-width: 1280px) {
.signage-playback-layout {
align-items: start;
grid-template-columns: minmax(0, 1fr) minmax(28rem, 1fr);
}
}
/* --------------------------------------------------------------------------------------------- /* ---------------------------------------------------------------------------------------------
* Displays and providers. * Displays and providers.
* ------------------------------------------------------------------------------------------- */ * ------------------------------------------------------------------------------------------- */
@@ -335,24 +390,112 @@
gap: var(--signage-gap); gap: var(--signage-gap);
} }
.signage-group { .signage-pairing-steps {
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--signage-gap-tight); 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); 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 { .signage-group__header {
align-items: center; align-items: center;
display: flex; display: flex;
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
} }
.signage-group__header .ek-button:first-of-type { .signage-group__header > .ek-button {
margin-inline-start: auto; 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 { .signage-confirm {
align-items: center; align-items: center;
background: var(--ekklesia-status-danger-background); background: var(--ekklesia-status-danger-background);
@@ -369,18 +512,167 @@
margin: 0; margin: 0;
} }
.signage-registration-guide { .signage-audiences {
background: var(--ekklesia-background-inset); 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); border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--ekklesia-spacing-xs); gap: var(--signage-gap-tight);
margin-block-end: var(--signage-gap); margin-inline-start: var(--ekklesia-spacing-md);
padding: 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; 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 { .signage-provider__id {
+365 -84
View File
@@ -3,7 +3,12 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SignageDashboard } from "../../src/ui/SignageDashboard.js"; import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js"; import {
createFakeHost,
errorResponse,
FAKE_SETTINGS,
jsonResponse,
} from "./support/fake-host.js";
/** /**
* The management UI's behaviour, mounted as React rather than inspected as source. * The management UI's behaviour, mounted as React rather than inspected as source.
@@ -14,10 +19,28 @@ import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./su
*/ */
// React needs this to run effects the way a browser does. // React needs this to run effects the way a browser does.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLElement; let container: HTMLElement;
let root: Root; let root: Root;
const DISPLAY_SETUP_URL = "https://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> { async function render(element: React.ReactElement): Promise<void> {
await act(async () => { await act(async () => {
@@ -70,7 +93,9 @@ function buttonNamed(label: string): HTMLButtonElement | undefined {
} }
const GROUP = { const GROUP = {
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }], channels: [
{ id: "chan-1", isDefault: true, key: "default", name: "Default" },
],
id: "group-1", id: "group-1",
name: "Main", name: "Main",
playback: { playback: {
@@ -131,6 +156,7 @@ const TIMELINE = {
}; };
beforeEach(() => { beforeEach(() => {
copiedText.length = 0;
container = document.createElement("div"); container = document.createElement("div");
document.body.append(container); document.body.append(container);
root = createRoot(container); root = createRoot(container);
@@ -149,9 +175,21 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"], permissions: ["signage.view", "signage.playback.control"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{ {
method: "GET", method: "GET",
path: "playback/group-1", path: "playback/group-1",
@@ -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( expect(
"welcome", container.querySelector("[data-testid='signage-current']")?.textContent,
); ).toContain("welcome");
expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s"); expect(
container.querySelector("[data-testid='signage-remaining']")?.textContent,
).toBe("4s");
await click(buttonNamed("Pause")); await click(buttonNamed("Pause"));
await click(buttonNamed("Next")); await click(buttonNamed("Next"));
@@ -190,37 +230,73 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view"], permissions: ["signage.view"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
], ],
}); });
await render(<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. // The current slide is still legible; the buttons that would be refused are simply not offered.
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull(); expect(
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull(); container.querySelector("[data-testid='signage-current']"),
).not.toBeNull();
expect(
container.querySelector("[data-testid='signage-controls']"),
).toBeNull();
}); });
it("shows which timeline remains active after the dashboard data is reloaded", async () => { it("shows which timeline remains active after the dashboard data is reloaded", async () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"], permissions: ["signage.view", "signage.playback.control"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{ {
method: "GET", method: "GET",
path: "timelines", path: "timelines",
respond: () => jsonResponse({ timelines: [TIMELINE] }), respond: () => jsonResponse({ timelines: [TIMELINE] }),
}, },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, {
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
expect(container.textContent).toContain("Active: Sunday morning"); expect(container.textContent).toContain("Active: Sunday morning");
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
@@ -235,12 +311,20 @@ describe("Signage dashboard", () => {
it("keeps a failed activation visible instead of pretending it succeeded", async () => { it("keeps a failed activation visible instead of pretending it succeeded", async () => {
const inactiveGroup = { const inactiveGroup = {
...GROUP, ...GROUP,
playback: { ...GROUP.playback, activeTimelineId: null, currentSelectionId: null }, playback: {
...GROUP.playback,
activeTimelineId: null,
currentSelectionId: null,
},
}; };
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control"], permissions: ["signage.view", "signage.playback.control"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{ {
method: "GET", method: "GET",
path: "channel-groups", path: "channel-groups",
@@ -251,18 +335,30 @@ describe("Signage dashboard", () => {
path: "timelines", path: "timelines",
respond: () => jsonResponse({ timelines: [TIMELINE] }), respond: () => jsonResponse({ timelines: [TIMELINE] }),
}, },
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, {
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
{ {
method: "POST", method: "POST",
path: "playback/group-1/activate", path: "playback/group-1/activate",
respond: () => respond: () =>
errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409), errorResponse(
"SIGNAGE_CONFLICT",
"The request conflicts with the current state.",
409,
),
}, },
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Timelines", (candidate) => candidate.textContent === "Timelines",
@@ -280,27 +376,54 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"], permissions: ["signage.view", "signage.timelines.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
{ {
method: "POST", method: "POST",
path: "timelines", path: "timelines",
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400), respond: () =>
errorResponse(
"SIGNAGE_INVALID_REQUEST",
"The request is not valid.",
400,
),
}, },
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines", (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 type(input, "Sunday morning");
await click(buttonNamed("Create")); await click(buttonNamed("Create"));
@@ -314,10 +437,26 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.timelines.manage"], permissions: ["signage.view", "signage.timelines.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, path: "settings",
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
{
method: "GET",
path: "images",
respond: () => jsonResponse({ images: [] }),
},
{ {
method: "GET", method: "GET",
path: "timelines", path: "timelines",
@@ -357,7 +496,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines", (tab) => tab.textContent === "Timelines",
@@ -365,7 +504,9 @@ describe("Signage dashboard", () => {
); );
await click(buttonNamed("Sunday morning")); await click(buttonNamed("Sunday morning"));
expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull(); expect(
container.querySelector("[data-testid='signage-timeline-editor']"),
).not.toBeNull();
expect(container.textContent).toContain("This timeline has no slots"); expect(container.textContent).toContain("This timeline has no slots");
}); });
@@ -376,10 +517,26 @@ describe("Signage dashboard", () => {
contributions: [], contributions: [],
permissions: ["signage.view"], permissions: ["signage.view"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{ {
method: "GET", method: "GET",
path: "providers", path: "providers",
@@ -407,7 +564,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers", (tab) => tab.textContent === "Providers",
@@ -422,15 +579,35 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view"], permissions: ["signage.view"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) }, },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "providers",
respond: () => jsonResponse({ providers: [] }),
},
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers", (tab) => tab.textContent === "Providers",
@@ -438,17 +615,35 @@ describe("Signage dashboard", () => {
); );
expect(container.textContent).toContain("No content providers registered"); expect(container.textContent).toContain("No content providers registered");
expect(container.textContent).toContain("Signage works on its own with native images"); expect(container.textContent).toContain(
"Signage works on its own with native images",
);
}); });
it("shows the display roster with online state and offline readiness", async () => { it("shows the display roster with online state and offline readiness", async () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"], permissions: ["signage.view", "signage.displays.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{ {
method: "GET", method: "GET",
path: "viewers", path: "viewers",
@@ -460,7 +655,11 @@ describe("Signage dashboard", () => {
channelId: "chan-1", channelId: "chan-1",
deviceId: null, deviceId: null,
id: "viewer-1", id: "viewer-1",
lastError: { atMs: Date.now(), code: "SLIDE_DECODE_FAILED", detail: null }, lastError: {
atMs: Date.now(),
code: "SLIDE_DECODE_FAILED",
detail: null,
},
lastSeenAtMs: Date.now() - 4000, lastSeenAtMs: Date.now() - 4000,
name: "Lobby north", name: "Lobby north",
offlineReady: true, offlineReady: true,
@@ -476,13 +675,21 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"), [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Displays",
),
); );
expect(container.textContent).toContain("Online"); expect(container.textContent).toContain("Online");
expect(container.textContent).toContain("12 cached"); expect(container.textContent).toContain("12 cached");
const display = container.querySelector(
"[data-testid='signage-display-viewer-1']",
);
expect(display?.closest(".signage-channel")?.textContent).toContain(
"Default",
);
// A sanitized code, never a stack or a message a browser happened to have. // A sanitized code, never a stack or a message a browser happened to have.
expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
}); });
@@ -490,7 +697,9 @@ describe("Signage dashboard", () => {
it("explains display registration and deletes a channel group only after confirmation", async () => { it("explains display registration and deletes a channel group only after confirmation", async () => {
const secondGroup = { const secondGroup = {
...GROUP, ...GROUP,
channels: [{ id: "chan-2", isDefault: true, key: "default", name: "Default" }], channels: [
{ id: "chan-2", isDefault: true, key: "default", name: "Default" },
],
id: "group-2", id: "group-2",
name: "Foyer", name: "Foyer",
playback: { ...GROUP.playback, groupId: "group-2" }, playback: { ...GROUP.playback, groupId: "group-2" },
@@ -500,15 +709,31 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"], permissions: ["signage.view", "signage.displays.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
method: "GET",
path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{ {
method: "GET", method: "GET",
path: "channel-groups", path: "channel-groups",
respond: () => jsonResponse({ groups: currentGroups }), respond: () => jsonResponse({ groups: currentGroups }),
}, },
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, {
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, method: "GET",
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) }, path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "viewers",
respond: () => jsonResponse({ viewers: [] }),
},
{ {
method: "DELETE", method: "DELETE",
path: "channel-groups/group-1", path: "channel-groups/group-1",
@@ -521,15 +746,29 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Displays", (candidate) => candidate.textContent === "Displays",
), ),
); );
expect(container.textContent).toContain("/display/signage"); const setupLink = [...container.querySelectorAll("a")].find(
expect(container.textContent).toContain("Provider audience key"); (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")); await click(buttonNamed("Delete group"));
expect(deleted).toEqual([]); expect(deleted).toEqual([]);
expect(container.textContent).toContain("Displays assigned here will move"); expect(container.textContent).toContain("Displays assigned here will move");
@@ -542,11 +781,31 @@ describe("Signage dashboard", () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view", "signage.assets.manage"], permissions: ["signage.view", "signage.assets.manage"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) }, respond: () => jsonResponse(FAKE_SETTINGS),
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) }, },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse(PLAYBACK_DETAIL),
},
{
method: "GET",
path: "images",
respond: () => jsonResponse({ images: [] }),
},
{ {
method: "POST", method: "POST",
path: /^images\?/u, path: /^images\?/u,
@@ -564,9 +823,11 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"), [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Images",
),
); );
const input = container.querySelector( const input = container.querySelector(
@@ -596,29 +857,49 @@ describe("Signage dashboard", () => {
const uploads = container.querySelector("[data-testid='signage-uploads']"); const uploads = container.querySelector("[data-testid='signage-uploads']");
expect(uploads?.textContent).toContain("done"); expect(uploads?.textContent).toContain("done");
expect(uploads?.textContent).toContain("failed"); expect(uploads?.textContent).toContain("failed");
expect(uploads?.textContent).toContain("The uploaded file is not a decodable image."); expect(uploads?.textContent).toContain(
"The uploaded file is not a decodable image.",
);
}); });
it("stays on screen when one panel's data cannot be loaded", async () => { it("stays on screen when one panel's data cannot be loaded", async () => {
const host = createFakeHost({ const host = createFakeHost({
permissions: ["signage.view"], permissions: ["signage.view"],
routes: [ routes: [
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) }, {
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) }, method: "GET",
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) }, path: "settings",
respond: () => jsonResponse(FAKE_SETTINGS),
},
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [GROUP] }),
},
{
method: "GET",
path: "timelines",
respond: () => jsonResponse({ timelines: [] }),
},
{ {
method: "GET", method: "GET",
path: "playback/group-1", path: "playback/group-1",
respond: () => respond: () =>
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500), errorResponse(
"SIGNAGE_OPERATION_FAILED",
"The operation could not be completed.",
500,
),
}, },
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
expect(container.textContent).toContain("Signage could not load this"); expect(container.textContent).toContain("Signage could not load this");
expect(container.textContent).toContain("The operation could not be completed."); expect(container.textContent).toContain(
"The operation could not be completed.",
);
// The rest of the dashboard is still usable: one failing panel is not a failed screen. // The rest of the dashboard is still usable: one failing panel is not a failed screen.
expect(container.querySelectorAll("[role='tab']").length).toBe(5); expect(container.querySelectorAll("[role='tab']").length).toBe(5);
}); });
+31 -7
View File
@@ -37,7 +37,11 @@ export function jsonResponse(body: unknown, status = 200): Response {
}); });
} }
export function errorResponse(code: string, message: string, status = 400): Response { export function errorResponse(
code: string,
message: string,
status = 400,
): Response {
return jsonResponse({ code, message }, status); return jsonResponse({ code, message }, status);
} }
@@ -45,6 +49,7 @@ export interface FakeHostOptions {
readonly permissions: readonly string[]; readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[]; readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[]; readonly contributions?: readonly ModuleContributionDescriptor[];
readonly displayUrl?: string;
readonly principal?: ModuleUiHostContext["principal"]; readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: ( readonly mount?: (
options: MountModuleContributionOptions, options: MountModuleContributionOptions,
@@ -69,15 +74,24 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
return route.respond({ init, path: normalized }); return route.respond({ init, path: normalized });
} }
} }
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404); return errorResponse(
"SIGNAGE_NOT_FOUND",
"The requested resource was not found.",
404,
);
}, },
}; };
const contributions: ModuleUiContributions = { const contributions: ModuleUiContributions = {
list: (capabilityId) => list: (capabilityId) =>
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [], capabilityId === "signage.slide-renderer"
? (options.contributions ?? [])
: [],
async mount(mountOptions) { async mount(mountOptions) {
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input }); mounted.push({
contributionId: mountOptions.contributionId,
input: mountOptions.input,
});
if (options.mount) { if (options.mount) {
return options.mount(mountOptions); return options.mount(mountOptions);
} }
@@ -95,8 +109,13 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
context: { context: {
api, api,
contributions, contributions,
display: {
url: options.displayUrl ?? "https://api.example.test/display/signage",
},
moduleId: "signage", moduleId: "signage",
permissions: { has: (permission) => options.permissions.includes(permission) }, permissions: {
has: (permission) => options.permissions.includes(permission),
},
principal: options.principal ?? { kind: "anonymous" }, principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) }, realtime: { subscribe: () => ({ close: () => undefined }) },
}, },
@@ -108,7 +127,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
/** The settings payload every screen loads first. */ /** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({ export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000, crossfadeMs: 1000,
moduleVersion: "0.1.0", moduleVersion: "0.1.1",
nativeImageDurationMs: 7000, nativeImageDurationMs: 7000,
nativePresentation: { nativePresentation: {
backgroundColor: "#000000", backgroundColor: "#000000",
@@ -118,5 +137,10 @@ export const FAKE_SETTINGS = Object.freeze({
}, },
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",
timezone: "America/Chicago", timezone: "America/Chicago",
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 }, uploads: {
batchMaxFiles: 20,
concurrency: 3,
maxBytes: 26_214_400,
maxPixels: 40_000_000,
},
}); });
+95
View File
@@ -0,0 +1,95 @@
import type { ModuleDatabase } from "@ekklesia/module-sdk";
import { describe, expect, it } from "vitest";
import { createTimelineStore } from "../src/server/store/drizzle-timelines.js";
import type { NewStoredSlot } from "../src/server/store/types.js";
const TIMELINE_ID = "00000000-0000-4000-8000-000000000001";
function slot(id: string): NewStoredSlot {
return {
durationOverrideMs: null,
eligibleFrom: null,
eligibleUntil: null,
enabled: true,
id,
itemId: "image-1",
kind: "pinned-item",
presentation: {
backgroundColor: null,
backgroundDarkened: null,
backgroundMode: null,
blurPixels: null,
foregroundShadow: null,
},
providerId: "signage.native-images",
sourceId: null,
timelineId: TIMELINE_ID,
};
}
/**
* A narrow database contract double for the production timeline store.
*
* Unlike the service-level memory store, it enforces the shipped PostgreSQL
* `signage_timeline_slots_position_check` on every insert and update. That is the boundary the
* deployed bug crossed: the old collision-avoidance algorithm used negative positions, so the
* actual database rejected a native-image slot before playback ever had content to resolve.
*/
function constraintDatabase(existing: readonly { id: string; position: number }[]): {
readonly database: ModuleDatabase;
readonly positions: number[];
} {
const positions: number[] = [];
const enforce = (values: { position?: unknown }): void => {
if (typeof values.position === "number") {
expect(values.position).toBeGreaterThanOrEqual(0);
positions.push(values.position);
}
};
const transaction = {
insert: () => ({
values: async (values: { position?: unknown }) => enforce(values),
}),
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => existing,
}),
}),
}),
update: () => ({
set: (values: { position?: unknown }) => ({
where: async () => enforce(values),
}),
}),
};
return {
database: {
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
} as unknown as ModuleDatabase,
positions,
};
}
describe("production timeline slot ordering", () => {
it("creates the first pinned native image without violating the database position constraint", async () => {
const { database, positions } = constraintDatabase([]);
await createTimelineStore(database).addSlot(slot("00000000-0000-4000-8000-000000000002"));
expect(positions).toEqual([1, 2, 0]);
});
it("reorders through a non-negative temporary range before writing the final order", async () => {
const first = "00000000-0000-4000-8000-000000000002";
const second = "00000000-0000-4000-8000-000000000003";
const { database, positions } = constraintDatabase([
{ id: first, position: 0 },
{ id: second, position: 1 },
]);
await createTimelineStore(database).reorderSlots(TIMELINE_ID, [second, first]);
expect(positions).toEqual([3, 4, 0, 1]);
});
});
+3 -3
View File
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
expect(manifest.manifestVersion).toBe(7); expect(manifest.manifestVersion).toBe(7);
expect(manifest.id).toBe("signage"); expect(manifest.id).toBe("signage");
expect(manifest.name).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" }); expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
}); });
@@ -142,7 +142,7 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => { describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { 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 () => { 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(response.status).toBe(200);
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
crossfadeMs: 400, crossfadeMs: 400,
moduleVersion: "0.1.0", moduleVersion: "0.1.1",
nativeImageDurationMs: 12_000, nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",