fix: Stage 0 Signage production readiness #18

Open
ekklesia-bot wants to merge 8 commits from stage0/signage-production-readiness into main
16 changed files with 3446 additions and 3034 deletions
Showing only changes of commit 7c17282616 - Show all commits
+1 -1
View File
@@ -20,7 +20,7 @@ export function DesktopOnly({ children }: { readonly children: ReactNode }): Rea
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
<p className="signage-narrow-notice__body">
{`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`}
{`Open this page on a screen at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, displays, and playback.`}
</p>
</div>
<div className="signage-desktop" data-testid="signage-desktop">
+33 -36
View File
@@ -1,9 +1,9 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
import { type ReactElement, useMemo, useState } from "react";
import { type ReactElement, useContext, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js";
import { useResource } from "./hooks.js";
import { createResourceCache, ResourceCacheContext, useResource } from "./hooks.js";
import { DisplaysPanel } from "./panels/DisplaysPanel.js";
import { ImagesPanel } from "./panels/ImagesPanel.js";
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
@@ -22,7 +22,20 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
*/
export function SignageDashboard({
export function SignageDashboard(props: {
readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>;
}): ReactElement {
// One cache for the life of this screen: everything the panels have loaded, shared between them.
const [cache] = useState(createResourceCache);
return (
<ResourceCacheContext.Provider value={cache}>
<SignageWorkspace {...props} />
</ResourceCacheContext.Provider>
);
}
function SignageWorkspace({
context,
copyText,
}: {
@@ -30,16 +43,15 @@ export function SignageDashboard({
readonly copyText: (value: string) => Promise<void>;
}): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]);
const cache = useContext(ResourceCacheContext);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
// A hidden screen is a usability choice; the server is what actually refuses, and making a
// request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view");
const settings = useResource(
() =>
mayView
? api.get<SettingsResponse>("settings")
: Promise.resolve(undefined),
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
[mayView],
mayView ? "settings" : undefined,
);
const groups = useResource(
() =>
@@ -47,14 +59,13 @@ export function SignageDashboard({
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
mayView ? "channel-groups" : undefined,
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage");
const mayManageTimelines = context.permissions.has(
"signage.timelines.manage",
);
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
const mayControl = context.permissions.has("signage.playback.control");
const mayManageDisplays = context.permissions.has("signage.displays.manage");
@@ -68,15 +79,10 @@ export function SignageDashboard({
}
const selectedGroupId =
groupId ??
(groups.state.status === "ready"
? groups.state.value?.groups[0]?.id
: undefined);
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
const selectedGroup =
groups.state.status === "ready"
? groups.state.value?.groups.find(
(candidate) => candidate.id === selectedGroupId,
)
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId)
: undefined;
return (
@@ -118,8 +124,7 @@ export function SignageDashboard({
{...(selectedGroup
? {
activeGroupName: selectedGroup.name,
activeTimelineId:
selectedGroup.playback.activeTimelineId,
activeTimelineId: selectedGroup.playback.activeTimelineId,
}
: {})}
api={api}
@@ -127,13 +132,15 @@ export function SignageDashboard({
onActivate={
mayControl && selectedGroupId !== undefined
? async (timelineId) => {
await api.send(
"POST",
`playback/${selectedGroupId}/activate`,
{
await api.send("POST", `playback/${selectedGroupId}/activate`, {
timelineId,
when: "now",
},
});
// Playback opens on what is now true, not on the
// timeline it was showing before.
cache?.write(
`playback/${selectedGroupId}`,
await api.get(`playback/${selectedGroupId}`),
);
groups.reload();
setTab("playback");
@@ -148,11 +155,7 @@ export function SignageDashboard({
},
{
content: (
<ImagesPanel
api={api}
canManage={mayManageAssets}
settings={loaded}
/>
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
),
id: "images",
label: "Images",
@@ -174,13 +177,7 @@ export function SignageDashboard({
label: "Displays",
},
{
content: (
<ProvidersPanel
api={api}
context={context}
settings={loaded}
/>
),
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
id: "providers",
label: "Providers",
},
+71 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { type SignageApiFailure, SignageRequestError } from "./api.js";
/**
@@ -21,15 +21,76 @@ export interface Resource<Value> {
reload(): void;
}
/**
* The last value each Signage resource loaded, shared by every panel on the screen.
*
* The tabs mount only the selected panel, so without this every tab switch threw its data away,
* flashed a spinner, and collapsed the page to its height before growing again — the "jumping" an
* operator saw. It also let two panels disagree: activating a timeline from Playback left Timelines
* showing the old one as inactive until a reload. Now a remounted panel starts from what was last
* known and refreshes quietly, and a reload anywhere updates every panel reading the same resource.
*/
export interface ResourceCache {
read(key: string): { readonly value: unknown } | undefined;
write(key: string, value: unknown): void;
subscribe(key: string, listener: (value: unknown) => void): () => void;
}
export function createResourceCache(): ResourceCache {
const values = new Map<string, { readonly value: unknown }>();
const listeners = new Map<string, Set<(value: unknown) => void>>();
return {
read: (key) => values.get(key),
subscribe(key, listener) {
const set = listeners.get(key) ?? new Set();
set.add(listener);
listeners.set(key, set);
return () => {
set.delete(listener);
if (set.size === 0) {
listeners.delete(key);
}
};
},
write(key, value) {
values.set(key, { value });
for (const listener of [...(listeners.get(key) ?? [])]) {
listener(value);
}
},
};
}
export const ResourceCacheContext = createContext<ResourceCache | undefined>(undefined);
export function useResource<Value>(
load: () => Promise<Value>,
dependencies: readonly unknown[],
/** Shares this resource with every other panel that names the same key. */
key?: string,
): Resource<Value> {
const [state, setState] = useState<Loadable<Value>>({ status: "loading" });
const cache = useContext(ResourceCacheContext);
const cached = key === undefined ? undefined : cache?.read(key);
const [state, setState] = useState<Loadable<Value>>(
cached === undefined
? { status: "loading" }
: { status: "ready", value: cached.value as Value },
);
const generation = useRef(0);
const loadRef = useRef(load);
loadRef.current = load;
useEffect(() => {
if (key === undefined || !cache) {
return;
}
const current = cache.read(key);
if (current !== undefined) {
setState({ status: "ready", value: current.value as Value });
}
return cache.subscribe(key, (value) => setState({ status: "ready", value: value as Value }));
}, [cache, key]);
const run = useCallback(() => {
generation.current += 1;
const token = generation.current;
@@ -38,7 +99,13 @@ export function useResource<Value>(
.then((value) => {
// A response from a request the panel has already moved on from must never overwrite a
// newer one.
if (token === generation.current) {
if (token !== generation.current) {
return;
}
if (key !== undefined && cache) {
// Every panel subscribed to this key, including this one, is updated by the write.
cache.write(key, value);
} else {
setState({ status: "ready", value });
}
})
@@ -58,7 +125,7 @@ export function useResource<Value>(
status: "failed",
});
});
}, []);
}, [cache, key]);
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
// change, which is the whole reason this takes one.
+1 -3
View File
@@ -24,9 +24,7 @@ export default defineModuleUi({
context={context}
copyText={async (value) => {
if (globalThis.navigator.clipboard === undefined) {
throw new Error(
"Clipboard access is not available in this browser.",
);
throw new Error("Clipboard access is not available in this browser.");
}
await globalThis.navigator.clipboard.writeText(value);
}}
+139 -172
View File
@@ -1,31 +1,42 @@
import {
Badge,
Button,
Field,
InlineNotice,
Panel,
Select,
TextInput,
} from "@ekklesia/ui";
import { Badge, Button, Field, InlineNotice, Panel, Select, TextInput } from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js";
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
import {
FailureNote,
formatSince,
OnlineBadge,
renderLoadable,
} from "./shared.js";
import { ConfirmButton, FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
/**
* The key content providers use to pick an audience's version of a slide, derived from the name an
* operator typed. It is an implementation detail: staff name an audience ("Online"), and only an
* operator who knows a provider expects a particular key needs the Advanced override.
*/
export function audienceKeyFor(name: string, taken: readonly string[]): string {
const base =
name
.normalize("NFKD")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "")
.slice(0, 48)
.replace(/-+$/u, "") || "audience";
const start = /^[a-z]/u.test(base) ? base : `audience-${base}`;
const reserved = new Set([...taken, "default"]);
let candidate = start;
for (let suffix = 2; reserved.has(candidate); suffix += 1) {
candidate = `${start}-${suffix}`;
}
return candidate;
}
function DisplayRow({
canManage,
groups,
nowMs,
onChannelChange,
onNameChange,
onRemove,
viewer,
}: {
readonly canManage: boolean;
@@ -33,31 +44,32 @@ function DisplayRow({
readonly nowMs: number;
readonly onChannelChange: (channelId: string) => void;
readonly onNameChange: (name: string) => void;
readonly onRemove: () => void;
readonly viewer: ViewerHealthView;
}): ReactElement {
return (
<div
className="signage-display-row"
data-testid={`signage-display-${viewer.id}`}
>
<div className="signage-display-row" data-testid={`signage-display-${viewer.id}`}>
<div className="signage-display-row__identity">
{canManage ? (
<TextInput
aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name}
onBlur={(event) =>
event.target.value !== viewer.name &&
onNameChange(event.target.value)
event.target.value !== viewer.name && onNameChange(event.target.value)
}
/>
) : (
<strong>{viewer.name}</strong>
)}
<OnlineBadge online={viewer.online} />
{viewer.deviceId === null ? (
// Opened by someone signed in — a test tab on a laptop, not a paired screen.
<Badge tone="neutral">Signed-in browser</Badge>
) : undefined}
</div>
<div className="signage-display-row__assignment">
{canManage ? (
<Field label="Audience channel">
<Field label="Audience">
{(control) => (
<Select
{...control}
@@ -82,18 +94,22 @@ function DisplayRow({
<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"}
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"}
</Badge>
{viewer.lastError === null ? undefined : (
<span
className="signage-display-row__error"
title={viewer.lastError.detail ?? undefined}
>
<span className="signage-display-row__error" title={viewer.lastError.detail ?? undefined}>
{viewer.lastError.code}
</span>
)}
{canManage && !viewer.online ? (
// Only an offline entry: a screen that is still connected would simply reappear.
<ConfirmButton
confirmLabel="Remove from the list"
label="Remove"
onConfirm={onRemove}
testId={`signage-display-remove-${viewer.id}`}
/>
) : undefined}
</div>
</div>
);
@@ -119,10 +135,12 @@ export function DisplaysPanel({
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
"channel-groups",
);
const viewers = useResource(
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
[],
"viewers",
);
const edit = useAction(() => {
groups.reload();
@@ -131,12 +149,8 @@ export function DisplaysPanel({
});
const viewerEdit = useAction(viewers.reload);
const [groupName, setGroupName] = useState("");
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">(
"idle",
);
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(
undefined,
);
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle");
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
const [channelDraft, setChannelDraft] = useState<{
groupId: string;
key: string;
@@ -148,15 +162,14 @@ export function DisplaysPanel({
});
useTick(5000);
const nowMs = Date.now();
const readyViewers =
viewers.state.status === "ready" ? viewers.state.value.viewers : [];
const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : [];
const updateViewer = (
viewerId: string,
changes: Record<string, string>,
): void => {
const updateViewer = (viewerId: string, changes: Record<string, string>): void => {
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
};
const removeViewer = (viewerId: string): void => {
viewerEdit.run(() => api.send("DELETE", `viewers/${viewerId}`));
};
return (
<div className="signage-columns">
@@ -164,14 +177,6 @@ export function DisplaysPanel({
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");
@@ -180,35 +185,39 @@ export function DisplaysPanel({
() => setCopyStatus("failed"),
);
}}
variant="primary"
>
Copy URL
Copy setup address
</Button>
<a
className="ek-button ek-focusable ek-button--secondary"
href={displaySetupUrl}
rel="noopener noreferrer"
target="_blank"
>
Preview here
</a>
</div>
)
}
description="Connect a screen in three short steps."
title="Pair a display"
>
<ol
className="signage-pairing-steps"
aria-label="Display pairing steps"
>
<ol className="signage-pairing-steps" aria-label="Display pairing steps">
<li>
<span className="signage-pairing-steps__number">1</span>
<span>
Open display setup on the screen that will show signage.
On the screen that will show signage, open the display setup address. Copy it from
here; opening it on this computer only previews the display.
</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>
<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>
<span>Name the display and choose its group below.</span>
</li>
</ol>
{displaySetupUrl === undefined ? (
@@ -218,18 +227,15 @@ export function DisplaysPanel({
) : undefined}
<span className="signage-copy-status" aria-live="polite">
{copyStatus === "copied"
? "Display setup URL copied."
? "Display setup address copied."
: copyStatus === "failed"
? "Could not copy the URL. Copy it from the newly opened setup page."
? "Could not copy the address. Use Preview here and copy it from the address bar."
: ""}
</span>
{viewers.state.status === "ready" &&
viewers.state.value.viewers.length === 0 ? (
{viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? (
<div className="signage-display-empty">
<strong>No displays paired yet</strong>
<span>
A newly approved screen appears below in its assigned channel.
</span>
<span>A newly approved screen appears below in its assigned channel.</span>
</div>
) : undefined}
</Panel>
@@ -265,21 +271,17 @@ export function DisplaysPanel({
</div>
) : undefined
}
description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
description="Displays in one group play together, on the same slide at the same moment. Use another group for screens that should run their own loop."
title="Channel groups and displays"
>
{renderLoadable(
groups.state,
(value) => {
const knownChannelIds = new Set(
value.groups.flatMap((group) =>
group.channels.map((channel) => channel.id),
),
value.groups.flatMap((group) => group.channels.map((channel) => channel.id)),
);
const unassigned = readyViewers.filter(
(viewer) =>
viewer.channelId === null ||
!knownChannelIds.has(viewer.channelId),
(viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId),
);
return (
<div className="signage-groups" data-testid="signage-groups">
@@ -287,9 +289,7 @@ export function DisplaysPanel({
<article className="signage-group" key={group.id}>
<header className="signage-group__header">
<div className="signage-group__identity">
<span className="signage-hierarchy-label">
Channel group
</span>
<span className="signage-hierarchy-label">Channel group</span>
<h3>{group.name}</h3>
</div>
<Badge tone="info">{group.playback.transport}</Badge>
@@ -308,9 +308,8 @@ export function DisplaysPanel({
{confirmGroupId === group.id ? (
<div className="signage-confirm" role="alert">
<p>
Delete {group.name}? Its playback history will be
removed. Displays assigned here will move to the
default channel of another group.
Delete {group.name}? Its playback history will be removed. Displays
assigned here will move to the default channel of another group.
</p>
<Button
disabled={edit.pending}
@@ -322,10 +321,7 @@ export function DisplaysPanel({
disabled={edit.pending}
onClick={() =>
edit.run(async () => {
await api.send(
"DELETE",
`channel-groups/${group.id}`,
);
await api.send("DELETE", `channel-groups/${group.id}`);
setConfirmGroupId(undefined);
})
}
@@ -339,13 +335,14 @@ export function DisplaysPanel({
<section className="signage-audiences">
<div className="signage-audiences__heading">
<div>
<span className="signage-hierarchy-label">
Audience channels
</span>
<h4>Who this group renders for</h4>
<span className="signage-hierarchy-label">Audiences</span>
<h4>Who is watching these displays</h4>
</div>
<span>
Displays inherit the channel they are assigned to.
Every display in this group shows the same slide at the same moment. Add
an audience only when some screens should get a different version of it,
such as wording for people watching online. Images look the same for every
audience.
</span>
</div>
<div className="signage-channel-list">
@@ -354,51 +351,39 @@ export function DisplaysPanel({
(viewer) => viewer.channelId === channel.id,
);
return (
<article
className="signage-channel"
key={channel.id}
>
<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 tone="success">Default audience</Badge>
) : undefined}
<span>Audience key</span>
<code>{channel.key}</code>
<Badge tone="neutral">
{`${assigned.length} display${assigned.length === 1 ? "" : "s"}`}
</Badge>
</div>
</div>
{canManage && !channel.isDefault ? (
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send(
"DELETE",
`channels/${channel.id}`,
),
)
<ConfirmButton
confirmLabel={
assigned.length === 0
? `Remove ${channel.name}`
: `Remove ${channel.name} and move its displays to the default audience`
}
variant="danger"
>
Remove channel
</Button>
disabled={edit.pending}
label="Remove audience"
onConfirm={() =>
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
}
testId={`signage-channel-remove-${channel.id}`}
/>
) : undefined}
</header>
<div className="signage-channel__displays">
<span className="signage-hierarchy-label">
Displays
</span>
<span className="signage-hierarchy-label">Displays</span>
{assigned.length === 0 ? (
<p className="signage-channel__empty">
No displays assigned.
</p>
<p className="signage-channel__empty">No displays assigned.</p>
) : (
assigned.map((viewer) => (
<DisplayRow
@@ -409,9 +394,8 @@ export function DisplaysPanel({
onChannelChange={(channelId) =>
updateViewer(viewer.id, { channelId })
}
onNameChange={(name) =>
updateViewer(viewer.id, { name })
}
onNameChange={(name) => updateViewer(viewer.id, { name })}
onRemove={() => removeViewer(viewer.id)}
viewer={viewer}
/>
))
@@ -423,19 +407,14 @@ export function DisplaysPanel({
</div>
{canManage ? (
<div
className="signage-channel-form"
data-testid="signage-add-channel"
>
<div className="signage-channel-form" data-testid="signage-add-channel">
<div className="signage-channel-form__heading">
<h5>Add an audience channel</h5>
<span>
The default channel always remains available.
</span>
<h5>Add an audience</h5>
<span>The default audience always remains.</span>
</div>
<Field
hint="Shown to administrators and when assigning displays."
label="Channel name"
hint="Shown when you assign displays, for example Online."
label="Audience name"
>
{(control) => (
<TextInput
@@ -444,25 +423,23 @@ export function DisplaysPanel({
onChange={(event) =>
setChannelDraft((current) => ({
groupId: group.id,
key:
current.groupId === group.id
? current.key
: "",
key: current.groupId === group.id ? current.key : "",
name: event.target.value,
}))
}
placeholder="Online audience"
value={
channelDraft.groupId === group.id
? channelDraft.name
: ""
}
placeholder="Online"
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
/>
)}
</Field>
<details className="signage-advanced">
<summary>Advanced</summary>
<Field
hint="Advanced: lowercase ID sent to content providers, e.g. online."
label="Audience key"
hint={`Only needed when a content module expects a particular key. Leave empty to use ${audienceKeyFor(
channelDraft.groupId === group.id ? channelDraft.name : "",
group.channels.map((channel) => channel.key),
)}.`}
label="Content key"
>
{(control) => (
<TextInput
@@ -472,40 +449,35 @@ export function DisplaysPanel({
setChannelDraft((current) => ({
groupId: group.id,
key: event.target.value,
name:
current.groupId === group.id
? current.name
: "",
name: current.groupId === group.id ? current.name : "",
}))
}
pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
placeholder="online"
value={
channelDraft.groupId === group.id
? channelDraft.key
: ""
}
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
/>
)}
</Field>
</details>
<Button
disabled={
edit.pending ||
channelDraft.groupId !== group.id ||
!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
channelDraft.key.trim() === "default" ||
channelDraft.name.trim() === ""
channelDraft.name.trim() === "" ||
(channelDraft.key.trim() !== "" &&
(!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
channelDraft.key.trim() === "default"))
}
onClick={() =>
edit.run(async () => {
await api.send(
"POST",
`channel-groups/${group.id}/channels`,
{
key: channelDraft.key.trim(),
await api.send("POST", `channel-groups/${group.id}/channels`, {
key:
channelDraft.key.trim() ||
audienceKeyFor(
channelDraft.name,
group.channels.map((channel) => channel.key),
),
name: channelDraft.name.trim(),
},
);
});
setChannelDraft({
groupId: "",
key: "",
@@ -515,7 +487,7 @@ export function DisplaysPanel({
}
variant="primary"
>
Add channel
Add audience
</Button>
</div>
) : undefined}
@@ -525,21 +497,16 @@ export function DisplaysPanel({
{unassigned.length === 0 ? undefined : (
<section className="signage-unassigned">
<span className="signage-hierarchy-label">
Needs assignment
</span>
<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 })
}
onChannelChange={(channelId) => updateViewer(viewer.id, { channelId })}
onNameChange={(name) => updateViewer(viewer.id, { name })}
onRemove={() => removeViewer(viewer.id)}
viewer={viewer}
/>
))}
+14 -8
View File
@@ -9,7 +9,7 @@ import {
import type { SignageApi } from "../api.js";
import { useAction, useResource } from "../hooks.js";
import type { ImageRecord, SettingsResponse } from "../types.js";
import { FailureNote, formatInstant, renderLoadable } from "./shared.js";
import { ConfirmButton, FailureNote, formatInstant, renderLoadable } from "./shared.js";
/**
* The native image library and its upload queue.
@@ -28,7 +28,11 @@ export function ImagesPanel({
readonly settings: SettingsResponse;
readonly canManage: boolean;
}): ReactElement {
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("images"),
[],
"images",
);
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
const queueRef = useRef<UploadQueue | undefined>(undefined);
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
@@ -204,13 +208,15 @@ export function ImagesPanel({
Rename
</Button>
)}
<Button
<ConfirmButton
confirmLabel="Delete this image"
disabled={remove.pending}
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
variant="danger"
>
Delete
</Button>
label="Delete"
onConfirm={() =>
remove.run(() => api.send("DELETE", `images/${image.id}`))
}
testId={`signage-image-delete-${image.id}`}
/>
</div>
) : undefined}
</li>
+70 -75
View File
@@ -1,14 +1,6 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import {
ActionBar,
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import { ActionBar, Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js";
import type {
@@ -18,12 +10,7 @@ import type {
TimelineSummary,
} from "../types.js";
import { PreviewPanel } from "./PreviewPanel.js";
import {
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
/**
* Live transport for one channel group.
@@ -50,10 +37,12 @@ export function PlaybackPanel({
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
"channel-groups",
);
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
"timelines",
);
const detail = useResource(
() =>
@@ -61,6 +50,7 @@ export function PlaybackPanel({
? Promise.resolve(undefined)
: api.get<PlaybackDetailView>(`playback/${groupId}`),
[groupId],
groupId === undefined ? undefined : `playback/${groupId}`,
);
const command = useAction(() => {
detail.reload();
@@ -75,6 +65,10 @@ export function PlaybackPanel({
: undefined;
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
useTick(1000);
useManagementUpdates(context, groupId, () => {
detail.reload();
groups.reload();
});
function run(path: string, body?: unknown): void {
if (groupId === undefined) {
@@ -88,9 +82,8 @@ export function PlaybackPanel({
return timelineId;
}
return (
timelines.state.value.timelines.find(
(timeline) => timeline.id === timelineId,
)?.name ?? timelineId
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
timelineId
);
}
@@ -130,10 +123,7 @@ export function PlaybackPanel({
value === undefined ? (
<EmptyState title="No channel group selected" />
) : (
<div
className="signage-playback"
data-testid="signage-playback"
>
<div className="signage-playback" data-testid="signage-playback">
<div className="signage-playback__status">
{value.snapshot.activeTimelineId === null ? undefined : (
<Badge tone="success">
@@ -151,9 +141,7 @@ export function PlaybackPanel({
>
{value.snapshot.transport}
</Badge>
{value.snapshot.blackout ? (
<Badge tone="danger">Blackout</Badge>
) : undefined}
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
{value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge>
) : undefined}
@@ -173,10 +161,7 @@ export function PlaybackPanel({
) : (
<section className="signage-playback__now">
<h3>Now showing</h3>
<dl
className="signage-playback__current"
data-testid="signage-current"
>
<dl className="signage-playback__current" data-testid="signage-current">
<dt>Provider</dt>
<dd>{value.current.providerId}</dd>
<dt>Item</dt>
@@ -192,12 +177,7 @@ export function PlaybackPanel({
: formatDuration(value.snapshot.remainingMs)}
</dd>
<dt>Started</dt>
<dd>
{formatInstant(
value.current.startedAtMs,
settings.timezone,
)}
</dd>
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
</dl>
</section>
)}
@@ -208,10 +188,7 @@ export function PlaybackPanel({
data-testid="signage-controls"
label="Playback controls"
>
<Button
disabled={command.pending}
onClick={() => run("previous")}
>
<Button disabled={command.pending} onClick={() => run("previous")}>
Previous
</Button>
{value.snapshot.transport === "paused" ? (
@@ -223,27 +200,17 @@ export function PlaybackPanel({
Resume
</Button>
) : (
<Button
disabled={command.pending}
onClick={() => run("pause")}
>
<Button disabled={command.pending} onClick={() => run("pause")}>
Pause
</Button>
)}
<Button
disabled={command.pending}
onClick={() => run("next")}
>
<Button disabled={command.pending} onClick={() => run("next")}>
Next
</Button>
<Button
disabled={command.pending}
onClick={() =>
run("blackout", { on: !value.snapshot.blackout })
}
variant={
value.snapshot.blackout ? "primary" : "secondary"
}
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
variant={value.snapshot.blackout ? "primary" : "secondary"}
>
{value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button>
@@ -255,10 +222,7 @@ export function PlaybackPanel({
Release hold
</Button>
)}
<Button
disabled={command.pending}
onClick={() => run("reload")}
>
<Button disabled={command.pending} onClick={() => run("reload")}>
Reload content
</Button>
</ActionBar>
@@ -274,17 +238,12 @@ export function PlaybackPanel({
{(control) => (
<Select
{...control}
onChange={(event) =>
setActivateId(event.target.value)
}
onChange={(event) => setActivateId(event.target.value)}
value={activateId}
>
<option value="">Choose a timeline…</option>
{loaded.timelines.map((timeline) => (
<option
key={timeline.id}
value={timeline.id}
>
<option key={timeline.id} value={timeline.id}>
{timeline.name}
</option>
))}
@@ -323,18 +282,11 @@ export function PlaybackPanel({
{value.history.length === 0 ? undefined : (
<div className="signage-playback__history">
<h4 className="signage-editor__subtitle">
Recently played
</h4>
<h4 className="signage-editor__subtitle">Recently played</h4>
<ul className="ek-list">
{value.history.slice(0, 8).map((entry) => (
<li
className="ek-list__item signage-row"
key={entry.id}
>
<span className="signage-row__name">
{entry.itemId}
</span>
<li className="ek-list__item signage-row" key={entry.id}>
<span className="signage-row__name">{entry.itemId}</span>
<span className="signage-playback__history-meta">
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
</span>
@@ -355,3 +307,46 @@ export function PlaybackPanel({
</div>
);
}
/**
* Follows Signage's management realtime channel, which carries revisions only (ADR 0005), and asks
* for fresh detail when this group's playback actually changed — so "Now showing" follows the wall
* instead of freezing until an operator presses something. One subscription per mounted panel,
* closed when the panel goes away.
*/
function useManagementUpdates(
context: ModuleUiHostContext,
groupId: string | undefined,
onChange: () => void,
): void {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (groupId === undefined) {
return;
}
let seen: string | undefined;
const subscription = context.realtime.subscribe("management-state", {
onSnapshot: (payload) => observe(payload),
onUpdate: (payload) => observe(payload),
});
function observe(payload: unknown): void {
const groups = (payload as { groups?: unknown } | null)?.groups;
if (!Array.isArray(groups)) {
return;
}
const mine = groups.find((group: { groupId?: unknown }) => group?.groupId === groupId) as
| { revision?: unknown; selectionRevision?: unknown }
| undefined;
if (!mine) {
return;
}
const marker = `${String(mine.revision)}:${String(mine.selectionRevision)}`;
if (seen !== undefined && marker !== seen) {
onChangeRef.current();
}
seen = marker;
}
return () => subscription.close();
}, [context, groupId]);
}
+11 -1
View File
@@ -2,6 +2,7 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EmptyState, Panel } from "@ekklesia/ui";
import { type ReactElement, useEffect, useRef } from "react";
import { SignageSlate } from "../../browser/slate.js";
import { createMemoryMediaCache, createMemoryStore } from "../../browser/storage.js";
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
import type { SignageApi } from "../api.js";
@@ -36,7 +37,16 @@ export function PreviewPanel({
return;
}
let stopped = false;
const controller = createDisplayController({ context, root, telemetry: false });
// The display's own rendering code, but nothing it keeps: a management browser is not a wall,
// so it persists no snapshot and caches no image bytes, and never restores one channel's last
// frame into another channel's preview.
const controller = createDisplayController({
context,
media: createMemoryMediaCache(),
root,
store: createMemoryStore(),
telemetry: false,
});
let handle: ReturnType<typeof setTimeout> | undefined;
async function pump(): Promise<void> {
+8 -21
View File
@@ -42,18 +42,15 @@ export function ProvidersPanel({
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[],
"providers",
);
const published = new Set(
context.contributions
.list(SLIDE_RENDERER_CAPABILITY)
.map((contribution) => contribution.id),
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
);
return (
<Panel
actions={
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
}
actions={<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>}
description="Running content sources available to timelines."
title="Content providers"
>
@@ -78,17 +75,12 @@ export function ProvidersPanel({
</TableHead>
<TableBody>
{value.providers.map((provider) => {
const rendererPublished = published.has(
provider.renderer.contributionId,
);
const isNative =
provider.renderer.contributionId === "signage.native-image";
const rendererPublished = published.has(provider.renderer.contributionId);
const isNative = provider.renderer.contributionId === "signage.native-image";
return (
<TableRow key={provider.providerId}>
<TableCell>
<span className="signage-provider__id">
{provider.providerId}
</span>
<span className="signage-provider__id">{provider.providerId}</span>
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
</TableCell>
<TableCell>{provider.contractVersion}</TableCell>
@@ -96,9 +88,7 @@ export function ProvidersPanel({
{isNative ? (
<Badge tone="info">Built in</Badge>
) : rendererPublished ? (
<Badge tone="success">
{provider.renderer.contributionId}
</Badge>
<Badge tone="success">{provider.renderer.contributionId}</Badge>
) : (
<Badge tone="warning">Renderer not published</Badge>
)}
@@ -113,10 +103,7 @@ export function ProvidersPanel({
)}
</TableCell>
<TableCell>
{formatInstant(
provider.lastSuccessAt,
settings.timezone,
)}
{formatInstant(provider.lastSuccessAt, settings.timezone)}
</TableCell>
</TableRow>
);
+47 -102
View File
@@ -26,6 +26,7 @@ import type {
TimelineSummary,
} from "../types.js";
import {
ConfirmButton,
FailureNote,
formatDuration,
formatInstant,
@@ -60,10 +61,12 @@ export function TimelinesPanel({
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
"timelines",
);
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[],
"providers",
);
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
const [newName, setNewName] = useState("");
@@ -92,9 +95,7 @@ export function TimelinesPanel({
<Button
disabled={create.pending || newName.trim().length === 0}
onClick={() =>
create.run(() =>
api.send("POST", "timelines", { name: newName.trim() }),
)
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
}
variant="primary"
>
@@ -126,10 +127,7 @@ export function TimelinesPanel({
</TableHead>
<TableBody>
{value.timelines.map((timeline) => (
<TableRow
key={timeline.id}
selected={timeline.id === selectedId}
>
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
<TableCell>
<div className="signage-timeline-name">
<Button
@@ -141,16 +139,13 @@ export function TimelinesPanel({
</Button>
{timeline.id === activeTimelineId ? (
<Badge tone="success">
{activeGroupName
? `Active in ${activeGroupName}`
: "Active"}
{activeGroupName ? `Active in ${activeGroupName}` : "Active"}
</Badge>
) : undefined}
</div>
</TableCell>
<TableCell>
{timeline.eligibleFrom === null &&
timeline.eligibleUntil === null
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
? "Always"
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
</TableCell>
@@ -158,18 +153,11 @@ export function TimelinesPanel({
<TableCell>
{onActivate === undefined ? undefined : (
<Button
disabled={
activation.pending ||
timeline.id === activeTimelineId
}
onClick={() =>
activation.run(() => onActivate(timeline.id))
}
disabled={activation.pending || timeline.id === activeTimelineId}
onClick={() => activation.run(() => onActivate(timeline.id))}
variant="primary"
>
{timeline.id === activeTimelineId
? "Active"
: "Activate"}
{timeline.id === activeTimelineId ? "Active" : "Activate"}
</Button>
)}
</TableCell>
@@ -191,11 +179,7 @@ export function TimelinesPanel({
key={selectedId}
onChanged={timelines.reload}
onClose={() => setSelectedId(undefined)}
providers={
providers.state.status === "ready"
? providers.state.value.providers
: []
}
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
settings={settings}
timelineId={selectedId}
/>
@@ -224,19 +208,19 @@ function TimelineEditor({
const detail = useResource(
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
[timelineId],
`timelines/${timelineId}`,
);
const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("images"),
[],
"images",
);
const edit = useAction(() => {
detail.reload();
onChanged();
});
const [providerId, setProviderId] = useState("");
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>(
[],
);
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
function loadProvider(nextProviderId: string): void {
@@ -273,10 +257,7 @@ function TimelineEditor({
<h3 className="signage-editor__title">{value.timeline.name}</h3>
{canManage ? (
<div
className="signage-editor__add"
data-testid="signage-add-source"
>
<div className="signage-editor__add" data-testid="signage-add-source">
<Field
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
label="Content provider"
@@ -289,10 +270,7 @@ function TimelineEditor({
>
<option value="">Choose a provider…</option>
{providers.map((provider) => (
<option
key={provider.providerId}
value={provider.providerId}
>
<option key={provider.providerId} value={provider.providerId}>
{provider.providerId}
</option>
))}
@@ -348,32 +326,24 @@ function TimelineEditor({
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send(
"POST",
`timelines/${timelineId}/slots`,
{
api.send("POST", `timelines/${timelineId}/slots`, {
sourceId: source.id,
},
),
}),
)
}
>
Add slot
</Button>
<Button
<ConfirmButton
confirmLabel="Remove this source"
disabled={edit.pending}
onClick={() =>
label="Remove"
onConfirm={() =>
edit.run(() =>
api.send(
"DELETE",
`timelines/${timelineId}/sources/${source.id}`,
),
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
)
}
variant="danger"
>
Remove
</Button>
/>
</>
) : undefined}
</div>
@@ -403,8 +373,7 @@ function TimelineEditor({
(candidate) => candidate.id === slot.sourceId,
);
const image =
slot.providerId === NATIVE_PROVIDER_ID &&
images.state.status === "ready"
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready"
? images.state.value.images.find(
(candidate) => candidate.id === slot.itemId,
)
@@ -415,8 +384,7 @@ function TimelineEditor({
<TableCell>
{slot.kind === "provider-source"
? (source?.label ?? "Removed source")
: (image?.displayName ??
`${slot.providerId} · ${slot.itemId}`)}
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
</TableCell>
<TableCell>
{slot.durationOverrideMs === null
@@ -430,13 +398,9 @@ function TimelineEditor({
label={slot.enabled ? "On" : "Off"}
onChange={(event) =>
edit.run(() =>
api.send(
"PATCH",
`timelines/${timelineId}/slots/${slot.id}`,
{
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
enabled: event.target.checked,
},
),
}),
)
}
/>
@@ -448,63 +412,44 @@ function TimelineEditor({
disabled={index === 0 || edit.pending}
onClick={() =>
edit.run(() =>
api.send(
"PUT",
`timelines/${timelineId}/slot-order`,
{
api.send("PUT", `timelines/${timelineId}/slot-order`, {
slotIds: swap(
value.slots.map(
(candidate) => candidate.id,
),
value.slots.map((candidate) => candidate.id),
index,
index - 1,
),
},
),
}),
)
}
>
Up
</Button>
<Button
disabled={
index === value.slots.length - 1 ||
edit.pending
}
disabled={index === value.slots.length - 1 || edit.pending}
onClick={() =>
edit.run(() =>
api.send(
"PUT",
`timelines/${timelineId}/slot-order`,
{
api.send("PUT", `timelines/${timelineId}/slot-order`, {
slotIds: swap(
value.slots.map(
(candidate) => candidate.id,
),
value.slots.map((candidate) => candidate.id),
index,
index + 1,
),
},
),
}),
)
}
>
Down
</Button>
<Button
<ConfirmButton
confirmLabel="Remove this slot"
disabled={edit.pending}
onClick={() =>
label="Remove"
onConfirm={() =>
edit.run(() =>
api.send(
"DELETE",
`timelines/${timelineId}/slots/${slot.id}`,
),
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
)
}
variant="danger"
>
Remove
</Button>
/>
</div>
) : undefined}
</TableCell>
@@ -529,18 +474,18 @@ function TimelineEditor({
>
Duplicate
</Button>
<Button
<ConfirmButton
confirmLabel={`Delete ${value.timeline.name}`}
disabled={edit.pending}
onClick={() =>
label="Delete timeline"
onConfirm={() =>
edit.run(async () => {
await api.send("DELETE", `timelines/${timelineId}`);
onClose();
})
}
variant="danger"
>
Delete timeline
</Button>
testId="signage-timeline-delete"
/>
</div>
) : undefined}
</div>
+79 -2
View File
@@ -1,5 +1,5 @@
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui";
import type { ReactElement, ReactNode } from "react";
import { Badge, Button, ErrorState, LoadingState } from "@ekklesia/ui";
import { type ReactElement, type ReactNode, useEffect, useState } from "react";
import type { SignageApiFailure } from "../api.js";
import type { Loadable } from "../hooks.js";
@@ -89,3 +89,80 @@ export function formatSince(atMs: number | null, nowMs: number): string {
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
}
/** How long an armed destructive button waits for its second press before standing down. */
export const CONFIRM_RESET_MS = 6000;
/**
* A moderate destructive action, confirmed in place (DESIGN §21.3) — never with a browser dialog.
*
* The first press arms it: the button says plainly what will happen and a Cancel appears beside it.
* A second, deliberate press performs the action. Walking away, pressing Cancel, or Escape disarms
* it, so an armed button can never be left waiting for an accidental click.
*/
export function ConfirmButton({
label,
confirmLabel,
disabled,
onConfirm,
testId,
}: {
readonly label: string;
/** What the second press does, in words: "Delete this image", not "Are you sure?". */
readonly confirmLabel: string;
readonly disabled?: boolean;
readonly onConfirm: () => void;
readonly testId?: string;
}): ReactElement {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!armed) {
return;
}
const timer = setTimeout(() => setArmed(false), CONFIRM_RESET_MS);
return () => clearTimeout(timer);
}, [armed]);
if (!armed) {
return (
<Button
data-testid={testId}
disabled={disabled}
onClick={() => setArmed(true)}
variant="danger"
>
{label}
</Button>
);
}
return (
<fieldset
className="signage-confirm-inline"
onKeyDown={(event) => {
if (event.key === "Escape") {
setArmed(false);
}
}}
>
<Button
autoFocus
data-testid={testId === undefined ? undefined : `${testId}-confirm`}
disabled={disabled}
onClick={() => {
setArmed(false);
onConfirm();
}}
variant="danger"
>
{confirmLabel}
</Button>
<Button
data-testid={testId === undefined ? undefined : `${testId}-cancel`}
onClick={() => setArmed(false)}
variant="quiet"
>
Cancel
</Button>
</fieldset>
);
}
+22 -14
View File
@@ -176,10 +176,7 @@
.signage-image-grid {
display: grid;
gap: var(--signage-gap);
grid-template-columns: repeat(
auto-fill,
minmax(var(--signage-thumbnail-width), 1fr)
);
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr));
list-style: none;
margin: 0;
padding: 0;
@@ -507,14 +504,30 @@
padding: var(--ekklesia-spacing-sm);
}
.signage-advanced > summary {
color: var(--ekklesia-text-secondary);
cursor: pointer;
font-size: var(--ekklesia-typography-size-sm);
}
.signage-advanced[open] > summary {
margin-block-end: var(--signage-gap-tight);
}
.signage-confirm-inline {
align-items: center;
display: inline-flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
.signage-confirm p {
flex: 1 1 30rem;
margin: 0;
}
.signage-audiences {
border-block-start: var(--ekklesia-border-width) solid
var(--ekklesia-border-divider);
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
display: grid;
gap: var(--signage-gap);
padding-block-start: var(--signage-gap);
@@ -545,8 +558,7 @@
.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-inline-start: var(--ekklesia-spacing-2xs) solid var(--ekklesia-accent-base);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--signage-gap-tight);
@@ -584,8 +596,7 @@
}
.signage-channel__displays {
border-block-start: var(--ekklesia-border-width) solid
var(--ekklesia-border-divider);
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
display: grid;
gap: var(--signage-gap-tight);
padding-block-start: var(--signage-gap-tight);
@@ -603,10 +614,7 @@
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
);
grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(14rem, 1.2fr);
padding: var(--ekklesia-spacing-sm);
}
+32 -82
View File
@@ -3,12 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
import {
createFakeHost,
errorResponse,
FAKE_SETTINGS,
jsonResponse,
} from "./support/fake-host.js";
import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
/**
* The management UI's behaviour, mounted as React rather than inspected as source.
@@ -19,18 +14,14 @@ import {
*/
// React needs this to run effects the way a browser does.
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLElement;
let root: Root;
const DISPLAY_SETUP_URL = "https://api.example.test/display/signage";
const copiedText: string[] = [];
function dashboard(
context: Parameters<typeof SignageDashboard>[0]["context"],
): React.ReactElement {
function dashboard(context: Parameters<typeof SignageDashboard>[0]["context"]): React.ReactElement {
return (
<SignageDashboard
context={context}
@@ -93,9 +84,7 @@ function buttonNamed(label: string): HTMLButtonElement | undefined {
}
const GROUP = {
channels: [
{ id: "chan-1", isDefault: true, key: "default", name: "Default" },
],
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
id: "group-1",
name: "Main",
playback: {
@@ -208,12 +197,10 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context));
expect(
container.querySelector("[data-testid='signage-current']")?.textContent,
).toContain("welcome");
expect(
container.querySelector("[data-testid='signage-remaining']")?.textContent,
).toBe("4s");
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
"welcome",
);
expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s");
await click(buttonNamed("Pause"));
await click(buttonNamed("Next"));
@@ -256,12 +243,8 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context));
// The current slide is still legible; the buttons that would be refused are simply not offered.
expect(
container.querySelector("[data-testid='signage-current']"),
).not.toBeNull();
expect(
container.querySelector("[data-testid='signage-controls']"),
).toBeNull();
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull();
});
it("shows which timeline remains active after the dashboard data is reloaded", async () => {
@@ -349,11 +332,7 @@ describe("Signage dashboard", () => {
method: "POST",
path: "playback/group-1/activate",
respond: () =>
errorResponse(
"SIGNAGE_CONFLICT",
"The request conflicts with the current state.",
409,
),
errorResponse("SIGNAGE_CONFLICT", "The request conflicts with the current state.", 409),
},
],
});
@@ -404,12 +383,7 @@ describe("Signage dashboard", () => {
{
method: "POST",
path: "timelines",
respond: () =>
errorResponse(
"SIGNAGE_INVALID_REQUEST",
"The request is not valid.",
400,
),
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400),
},
],
});
@@ -421,9 +395,7 @@ describe("Signage dashboard", () => {
),
);
const input = container.querySelector(
"input[type='text']",
) as HTMLInputElement;
const input = container.querySelector("input[type='text']") as HTMLInputElement;
await type(input, "Sunday morning");
await click(buttonNamed("Create"));
@@ -504,9 +476,7 @@ describe("Signage dashboard", () => {
);
await click(buttonNamed("Sunday morning"));
expect(
container.querySelector("[data-testid='signage-timeline-editor']"),
).not.toBeNull();
expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull();
expect(container.textContent).toContain("This timeline has no slots");
});
@@ -615,9 +585,7 @@ describe("Signage dashboard", () => {
);
expect(container.textContent).toContain("No content providers registered");
expect(container.textContent).toContain(
"Signage works on its own with native images",
);
expect(container.textContent).toContain("Signage works on its own with native images");
});
it("shows the display roster with online state and offline readiness", async () => {
@@ -677,19 +645,13 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Displays",
),
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
);
expect(container.textContent).toContain("Online");
expect(container.textContent).toContain("12 cached");
const display = container.querySelector(
"[data-testid='signage-display-viewer-1']",
);
expect(display?.closest(".signage-channel")?.textContent).toContain(
"Default",
);
const display = container.querySelector("[data-testid='signage-display-viewer-1']");
expect(display?.closest(".signage-channel")?.textContent).toContain("Default");
// A sanitized code, never a stack or a message a browser happened to have.
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
});
@@ -697,9 +659,7 @@ describe("Signage dashboard", () => {
it("explains display registration and deletes a channel group only after confirmation", async () => {
const secondGroup = {
...GROUP,
channels: [
{ id: "chan-2", isDefault: true, key: "default", name: "Default" },
],
channels: [{ id: "chan-2", isDefault: true, key: "default", name: "Default" }],
id: "group-2",
name: "Foyer",
playback: { ...GROUP.playback, groupId: "group-2" },
@@ -754,21 +714,21 @@ describe("Signage dashboard", () => {
);
const setupLink = [...container.querySelectorAll("a")].find(
(candidate) => candidate.textContent?.trim() === "Open display setup",
(candidate) => candidate.textContent?.trim() === "Preview here",
);
expect(setupLink?.href).toBe(DISPLAY_SETUP_URL);
expect(setupLink?.target).toBe("_blank");
expect(setupLink?.rel).toContain("noopener");
await click(buttonNamed("Copy URL"));
await click(buttonNamed("Copy setup address"));
expect(copiedText).toEqual([DISPLAY_SETUP_URL]);
expect(container.textContent).toContain("Display setup URL copied.");
expect(container.textContent).toContain("Display setup address 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",
);
// Plain language for the audience concept, and no internal key on the page by default.
expect(container.textContent).toContain("Audiences");
expect(container.textContent).toContain("Images look the same for every audience.");
expect(container.textContent).toContain("Audience name");
expect(container.textContent).not.toContain("Audience key");
expect(container.querySelector("details.signage-advanced")?.hasAttribute("open")).toBe(false);
await click(buttonNamed("Delete group"));
expect(deleted).toEqual([]);
expect(container.textContent).toContain("Displays assigned here will move");
@@ -825,9 +785,7 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context));
await click(
[...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Images",
),
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
);
const input = container.querySelector(
@@ -857,9 +815,7 @@ describe("Signage dashboard", () => {
const uploads = container.querySelector("[data-testid='signage-uploads']");
expect(uploads?.textContent).toContain("done");
expect(uploads?.textContent).toContain("failed");
expect(uploads?.textContent).toContain(
"The uploaded file is not a decodable image.",
);
expect(uploads?.textContent).toContain("The uploaded file is not a decodable image.");
});
it("stays on screen when one panel's data cannot be loaded", async () => {
@@ -885,11 +841,7 @@ describe("Signage dashboard", () => {
method: "GET",
path: "playback/group-1",
respond: () =>
errorResponse(
"SIGNAGE_OPERATION_FAILED",
"The operation could not be completed.",
500,
),
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500),
},
],
});
@@ -897,9 +849,7 @@ describe("Signage dashboard", () => {
await render(dashboard(host.context));
expect(container.textContent).toContain("Signage could not load this");
expect(container.textContent).toContain(
"The operation could not be completed.",
);
expect(container.textContent).toContain("The operation could not be completed.");
// The rest of the dashboard is still usable: one failing panel is not a failed screen.
expect(container.querySelectorAll("[role='tab']").length).toBe(5);
});
+414
View File
@@ -0,0 +1,414 @@
// @vitest-environment happy-dom
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { audienceKeyFor } from "../../src/ui/panels/DisplaysPanel.js";
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
import {
createFakeHost,
FAKE_SETTINGS,
type FakeHostRoute,
jsonResponse,
} from "./support/fake-host.js";
/**
* Stage 0 production-readiness regressions for the management screen.
*
* Each case is a defect an operator met: an activation that looked lost after changing tabs, a
* screen that collapsed to a spinner on every tab switch, deletions with no confirmation, an
* internal key presented as something to fill in, and test displays nobody could remove.
*/
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
async function settle(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
async function click(element: Element | null | undefined): Promise<void> {
if (!element) {
throw new Error("Expected an element to click.");
}
await act(async () => (element as HTMLElement).click());
await settle();
}
function button(label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll("button")].find(
(candidate) => candidate.textContent?.trim() === label,
) as HTMLButtonElement | undefined;
}
function tab(label: string): Element | undefined {
return [...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === label,
);
}
async function choose(select: HTMLSelectElement, value: string): Promise<void> {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
await act(async () => {
setter?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
});
}
async function type(input: HTMLInputElement, value: string): Promise<void> {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(async () => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function timeline(id: string, name: string) {
return {
description: null,
eligibleFrom: null,
eligibleUntil: null,
id,
name,
revision: 1,
updatedAt: "2026-10-01T15:00:00.000Z",
};
}
function group(activeTimelineId: string | null) {
return {
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
id: "group-1",
name: "Main",
playback: {
activeTimelineId,
blackout: false,
currentSelectionId: null,
currentSlotId: null,
deadlineAtMs: null,
groupId: "group-1",
pendingTimelineId: null,
remainingMs: null,
revision: activeTimelineId === null ? 1 : 2,
scheduledActivationAtMs: null,
scheduledTimelineId: null,
selectionRevision: null,
serverNowMs: 1_700_000_000_000,
startedAtMs: null,
staticAssetId: null,
transport: activeTimelineId === null ? ("idle" as const) : ("playing" as const),
},
revision: 1,
};
}
/** A small, stateful Signage backend: activation really changes what later reads return. */
function backend(extra: readonly FakeHostRoute[] = []) {
let active: string | null = null;
const sent: string[] = [];
const routes: FakeHostRoute[] = [
...extra,
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [group(active)] }),
},
{
method: "GET",
path: "timelines",
respond: () =>
jsonResponse({
timelines: [timeline("timeline-1", "Sunday"), timeline("timeline-2", "Easter")],
}),
},
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) },
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse({ current: null, history: [], snapshot: group(active).playback }),
},
{ method: "GET", path: /^channels\/.*\/display-state$/u, respond: () => jsonResponse({}) },
{
method: "POST",
path: "playback/group-1/activate",
respond: ({ init }) => {
active = (JSON.parse(String(init?.body)) as { timelineId: string }).timelineId;
sent.push(`activate ${active}`);
return jsonResponse(group(active).playback);
},
},
];
return { routes, sent };
}
function mount(host: ReturnType<typeof createFakeHost>) {
return act(async () => {
root.render(<SignageDashboard context={host.context} copyText={() => Promise.resolve()} />);
});
}
describe("Signage dashboard — Stage 0 regressions", () => {
it("shows a timeline activated on Playback as active on Timelines too", async () => {
const server = backend();
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control", "signage.timelines.manage"],
routes: server.routes,
});
await mount(host);
await settle();
const activate = [...container.querySelectorAll("select")].find((select) =>
[...select.options].some((option) => option.textContent === "Choose a timeline…"),
) as HTMLSelectElement;
await choose(activate, "timeline-2");
await click(button("Now"));
expect(server.sent).toEqual(["activate timeline-2"]);
await click(tab("Timelines"));
const row = [...container.querySelectorAll("tr")].find((candidate) =>
candidate.textContent?.includes("Easter"),
);
expect(row?.textContent).toContain("Active in Main");
expect(container.querySelector("tr")?.parentElement).not.toBeNull();
});
it("keeps what it loaded when switching tabs, instead of flashing back to a loading state", async () => {
let slow = false;
const server = backend([
{
method: "GET",
path: "timelines",
respond: () =>
slow
? new Promise<Response>(() => undefined)
: jsonResponse({ timelines: [timeline("timeline-1", "Sunday")] }),
},
]);
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
await mount(host);
await settle();
await click(tab("Timelines"));
expect(container.textContent).toContain("Sunday");
// The next load never finishes. Coming back must still show the last known list.
slow = true;
await click(tab("Images"));
await click(tab("Timelines"));
expect(container.textContent).toContain("Sunday");
expect(container.textContent).not.toContain("Loading timelines…");
});
it("deletes an image only after a second, deliberate press, and Cancel stands it down", async () => {
const deleted: string[] = [];
const image = {
byteSize: 1000,
contentType: "image/png",
createdAt: "2026-10-01T15:00:00.000Z",
displayName: "Welcome",
expiresAt: null,
height: 1080,
id: "image-1",
originalFilename: "welcome.png",
renditions: [],
status: "ready",
width: 1920,
};
const server = backend([
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [image] }) },
{
method: "DELETE",
path: "images/image-1",
respond: ({ path }) => {
deleted.push(path);
return new Response(null, { status: 204 });
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.assets.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Images"));
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
expect(deleted).toEqual([]);
expect(button("Delete this image")).toBeDefined();
await click(container.querySelector("[data-testid='signage-image-delete-image-1-cancel']"));
expect(button("Delete this image")).toBeUndefined();
expect(deleted).toEqual([]);
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
await click(container.querySelector("[data-testid='signage-image-delete-image-1-confirm']"));
expect(deleted).toEqual(["images/image-1"]);
});
it("adds an audience from its name alone, deriving the content key", async () => {
const posted: unknown[] = [];
const server = backend([
{
method: "POST",
path: "channel-groups/group-1/channels",
respond: ({ init }) => {
posted.push(JSON.parse(String(init?.body)));
return jsonResponse({}, 201);
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Displays"));
const name = container.querySelector("input[placeholder='Online']") as HTMLInputElement;
await type(name, "Watching Online");
await click(button("Add audience"));
expect(posted).toEqual([{ key: "watching-online", name: "Watching Online" }]);
});
it("labels a signed-in browser and removes an offline entry only after confirmation", async () => {
const removed: string[] = [];
const viewer = {
cachedAssetCount: 0,
channelId: "chan-1",
deviceId: null,
id: "viewer-1",
lastError: null,
lastSeenAtMs: Date.now() - 600_000,
name: "New display",
offlineReady: false,
online: false,
reportedContributionId: null,
reportedProviderId: null,
reportedSelectionId: null,
reportedTimelineId: null,
};
const server = backend([
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [viewer] }) },
{
method: "DELETE",
path: "viewers/viewer-1",
respond: ({ path }) => {
removed.push(path);
return new Response(null, { status: 204 });
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Displays"));
expect(container.textContent).toContain("Signed-in browser");
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1']"));
expect(removed).toEqual([]);
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1-confirm']"));
expect(removed).toEqual(["viewers/viewer-1"]);
});
it("follows playback changes on the management channel, and stops when Playback is left", async () => {
const subscriptions: { closed: boolean; onUpdate: ((payload: unknown) => void) | undefined }[] =
[];
const server = backend();
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
Object.assign(host.context.realtime, {
subscribe(channel: string, handlers: { onUpdate?: (payload: unknown) => void }) {
const entry = { closed: false, onUpdate: handlers.onUpdate };
if (channel === "management-state") {
subscriptions.push(entry);
}
return { close: () => (entry.closed = true) };
},
});
await mount(host);
await settle();
const detailReads = () =>
host.requests.filter((request) => request.path === "playback/group-1").length;
const before = detailReads();
const live = subscriptions.find((entry) => !entry.closed);
await act(async () => {
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 1, selectionRevision: null }] });
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 2, selectionRevision: 4 }] });
});
await settle();
expect(detailReads()).toBeGreaterThan(before);
await click(tab("Images"));
expect(subscriptions.every((entry) => entry.closed)).toBe(true);
});
});
describe("audience content keys", () => {
it("derives a valid, unique key that is never the reserved default", () => {
expect(audienceKeyFor("Online", [])).toBe("online");
expect(audienceKeyFor("Online", ["online"])).toBe("online-2");
expect(audienceKeyFor("Default", [])).toBe("default-2");
expect(audienceKeyFor("2nd service", [])).toBe("audience-2nd-service");
expect(audienceKeyFor(" ", [])).toBe("audience");
expect(audienceKeyFor("Café — Room!", [])).toBe("cafe-room");
});
});
/** Source-level guards for two product-wide rules (DESIGN §2.1 and §21.1). */
describe("product rules in the browser source", () => {
function sources(directory: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const path = join(directory, entry);
if (statSync(path).isDirectory()) {
return sources(path);
}
return /\.(ts|tsx)$/u.test(entry) ? [path] : [];
});
}
const files = ["src/ui", "src/browser", "src/display"].flatMap(sources);
it("never uses a browser-native alert, confirm, or prompt", () => {
for (const file of files) {
expect(readFileSync(file, "utf8"), file).not.toMatch(
/\b(?:window\.|globalThis\.)?(?:alert|confirm|prompt)\s*\(/u,
);
}
});
it("never puts the platform's name in user-facing text", () => {
for (const file of files) {
const code = readFileSync(file, "utf8")
.replace(/\/\*[\s\S]*?\*\//gu, "")
.replace(/^\s*\/\/.*$/gmu, "")
.replace(/\{\/\*[\s\S]*?\*\/\}/gu, "");
// Quoted strings and JSX text; identifiers such as `EkklesiaUiStyles` are not text.
expect(code, file).not.toMatch(
/["'`][^"'`\n]*\bEkklesia\b[^"'`\n]*["'`]|>[^<{]*\bEkklesia\b/u,
);
}
});
});
+3 -13
View File
@@ -37,11 +37,7 @@ 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);
}
@@ -74,19 +70,13 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
return route.respond({ init, path: normalized });
}
}
return errorResponse(
"SIGNAGE_NOT_FOUND",
"The requested resource was not found.",
404,
);
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
},
};
const contributions: ModuleUiContributions = {
list: (capabilityId) =>
capabilityId === "signage.slide-renderer"
? (options.contributions ?? [])
: [],
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
async mount(mountOptions) {
mounted.push({
contributionId: mountOptions.contributionId,
+2 -1
View File
@@ -65,7 +65,8 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
};
return {
database: {
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) =>
operation(transaction),
} as unknown as ModuleDrizzleDatabase,
positions,
};