fix: Stage 0 Signage production readiness #18
@@ -20,7 +20,7 @@ export function DesktopOnly({ children }: { readonly children: ReactNode }): Rea
|
|||||||
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
|
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
|
||||||
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
|
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
|
||||||
<p className="signage-narrow-notice__body">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="signage-desktop" data-testid="signage-desktop">
|
<div className="signage-desktop" data-testid="signage-desktop">
|
||||||
|
|||||||
+165
-168
@@ -1,9 +1,9 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
|
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 { DesktopOnly } from "../browser/desktop-gate.js";
|
||||||
import { createSignageApi } from "./api.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 { DisplaysPanel } from "./panels/DisplaysPanel.js";
|
||||||
import { ImagesPanel } from "./panels/ImagesPanel.js";
|
import { ImagesPanel } from "./panels/ImagesPanel.js";
|
||||||
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
|
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
|
||||||
@@ -22,175 +22,172 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
|||||||
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
|
* 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.
|
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
|
||||||
*/
|
*/
|
||||||
export function SignageDashboard({
|
export function SignageDashboard(props: {
|
||||||
context,
|
readonly context: ModuleUiHostContext;
|
||||||
copyText,
|
readonly copyText: (value: string) => Promise<void>;
|
||||||
}: {
|
|
||||||
readonly context: ModuleUiHostContext;
|
|
||||||
readonly copyText: (value: string) => Promise<void>;
|
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
// One cache for the life of this screen: everything the panels have loaded, shared between them.
|
||||||
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
const [cache] = useState(createResourceCache);
|
||||||
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
return (
|
||||||
// request it will certainly refuse is noise in a log rather than security.
|
<ResourceCacheContext.Provider value={cache}>
|
||||||
const mayView = context.permissions.has("signage.view");
|
<SignageWorkspace {...props} />
|
||||||
const settings = useResource(
|
</ResourceCacheContext.Provider>
|
||||||
() =>
|
);
|
||||||
mayView
|
}
|
||||||
? api.get<SettingsResponse>("settings")
|
|
||||||
: Promise.resolve(undefined),
|
|
||||||
[mayView],
|
|
||||||
);
|
|
||||||
const groups = useResource(
|
|
||||||
() =>
|
|
||||||
mayView
|
|
||||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
|
||||||
: Promise.resolve(undefined),
|
|
||||||
[mayView],
|
|
||||||
);
|
|
||||||
const [tab, setTab] = useState("playback");
|
|
||||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
function SignageWorkspace({
|
||||||
const mayManageTimelines = context.permissions.has(
|
context,
|
||||||
"signage.timelines.manage",
|
copyText,
|
||||||
);
|
}: {
|
||||||
const mayControl = context.permissions.has("signage.playback.control");
|
readonly context: ModuleUiHostContext;
|
||||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
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],
|
||||||
|
mayView ? "settings" : undefined,
|
||||||
|
);
|
||||||
|
const groups = useResource(
|
||||||
|
() =>
|
||||||
|
mayView
|
||||||
|
? 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);
|
||||||
|
|
||||||
if (!mayView) {
|
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||||
return (
|
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
|
||||||
<EmptyState
|
const mayControl = context.permissions.has("signage.playback.control");
|
||||||
description="Ask an administrator for the signage.view permission."
|
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||||
title="No access to Signage"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedGroupId =
|
if (!mayView) {
|
||||||
groupId ??
|
return (
|
||||||
(groups.state.status === "ready"
|
<EmptyState
|
||||||
? groups.state.value?.groups[0]?.id
|
description="Ask an administrator for the signage.view permission."
|
||||||
: undefined);
|
title="No access to Signage"
|
||||||
const selectedGroup =
|
/>
|
||||||
groups.state.status === "ready"
|
);
|
||||||
? groups.state.value?.groups.find(
|
}
|
||||||
(candidate) => candidate.id === selectedGroupId,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
const selectedGroupId =
|
||||||
<>
|
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
|
||||||
<EkklesiaUiStyles />
|
const selectedGroup =
|
||||||
<DesktopOnly>
|
groups.state.status === "ready"
|
||||||
<PageHeader
|
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId)
|
||||||
description="Plan visual content, operate playback, and keep every screen connected."
|
: undefined;
|
||||||
eyebrow="Media"
|
|
||||||
title="Signage"
|
return (
|
||||||
/>
|
<>
|
||||||
{renderLoadable(
|
<EkklesiaUiStyles />
|
||||||
settings.state,
|
<DesktopOnly>
|
||||||
(loaded) =>
|
<PageHeader
|
||||||
loaded === undefined ? undefined : (
|
description="Plan visual content, operate playback, and keep every screen connected."
|
||||||
<div className="signage-workspace">
|
eyebrow="Media"
|
||||||
<Tabs
|
title="Signage"
|
||||||
label="Signage sections"
|
/>
|
||||||
onSelect={setTab}
|
{renderLoadable(
|
||||||
selectedId={tab}
|
settings.state,
|
||||||
tabs={[
|
(loaded) =>
|
||||||
{
|
loaded === undefined ? undefined : (
|
||||||
content: (
|
<div className="signage-workspace">
|
||||||
<PlaybackPanel
|
<Tabs
|
||||||
api={api}
|
label="Signage sections"
|
||||||
canControl={mayControl}
|
onSelect={setTab}
|
||||||
context={context}
|
selectedId={tab}
|
||||||
groupId={selectedGroupId}
|
tabs={[
|
||||||
onGroupChange={setGroupId}
|
{
|
||||||
settings={loaded}
|
content: (
|
||||||
/>
|
<PlaybackPanel
|
||||||
),
|
api={api}
|
||||||
id: "playback",
|
canControl={mayControl}
|
||||||
label: "Playback",
|
context={context}
|
||||||
},
|
groupId={selectedGroupId}
|
||||||
{
|
onGroupChange={setGroupId}
|
||||||
content: (
|
settings={loaded}
|
||||||
<TimelinesPanel
|
/>
|
||||||
{...(selectedGroup
|
),
|
||||||
? {
|
id: "playback",
|
||||||
activeGroupName: selectedGroup.name,
|
label: "Playback",
|
||||||
activeTimelineId:
|
},
|
||||||
selectedGroup.playback.activeTimelineId,
|
{
|
||||||
}
|
content: (
|
||||||
: {})}
|
<TimelinesPanel
|
||||||
api={api}
|
{...(selectedGroup
|
||||||
canManage={mayManageTimelines}
|
? {
|
||||||
onActivate={
|
activeGroupName: selectedGroup.name,
|
||||||
mayControl && selectedGroupId !== undefined
|
activeTimelineId: selectedGroup.playback.activeTimelineId,
|
||||||
? async (timelineId) => {
|
}
|
||||||
await api.send(
|
: {})}
|
||||||
"POST",
|
api={api}
|
||||||
`playback/${selectedGroupId}/activate`,
|
canManage={mayManageTimelines}
|
||||||
{
|
onActivate={
|
||||||
timelineId,
|
mayControl && selectedGroupId !== undefined
|
||||||
when: "now",
|
? async (timelineId) => {
|
||||||
},
|
await api.send("POST", `playback/${selectedGroupId}/activate`, {
|
||||||
);
|
timelineId,
|
||||||
groups.reload();
|
when: "now",
|
||||||
setTab("playback");
|
});
|
||||||
}
|
// Playback opens on what is now true, not on the
|
||||||
: undefined
|
// timeline it was showing before.
|
||||||
}
|
cache?.write(
|
||||||
settings={loaded}
|
`playback/${selectedGroupId}`,
|
||||||
/>
|
await api.get(`playback/${selectedGroupId}`),
|
||||||
),
|
);
|
||||||
id: "timelines",
|
groups.reload();
|
||||||
label: "Timelines",
|
setTab("playback");
|
||||||
},
|
}
|
||||||
{
|
: undefined
|
||||||
content: (
|
}
|
||||||
<ImagesPanel
|
settings={loaded}
|
||||||
api={api}
|
/>
|
||||||
canManage={mayManageAssets}
|
),
|
||||||
settings={loaded}
|
id: "timelines",
|
||||||
/>
|
label: "Timelines",
|
||||||
),
|
},
|
||||||
id: "images",
|
{
|
||||||
label: "Images",
|
content: (
|
||||||
},
|
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
||||||
{
|
),
|
||||||
content: (
|
id: "images",
|
||||||
<DisplaysPanel
|
label: "Images",
|
||||||
api={api}
|
},
|
||||||
canManage={mayManageDisplays}
|
{
|
||||||
copyText={copyText}
|
content: (
|
||||||
displaySetupUrl={context.display?.url}
|
<DisplaysPanel
|
||||||
onGroupsChanged={() => {
|
api={api}
|
||||||
setGroupId(undefined);
|
canManage={mayManageDisplays}
|
||||||
groups.reload();
|
copyText={copyText}
|
||||||
}}
|
displaySetupUrl={context.display?.url}
|
||||||
/>
|
onGroupsChanged={() => {
|
||||||
),
|
setGroupId(undefined);
|
||||||
id: "displays",
|
groups.reload();
|
||||||
label: "Displays",
|
}}
|
||||||
},
|
/>
|
||||||
{
|
),
|
||||||
content: (
|
id: "displays",
|
||||||
<ProvidersPanel
|
label: "Displays",
|
||||||
api={api}
|
},
|
||||||
context={context}
|
{
|
||||||
settings={loaded}
|
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
|
||||||
/>
|
id: "providers",
|
||||||
),
|
label: "Providers",
|
||||||
id: "providers",
|
},
|
||||||
label: "Providers",
|
]}
|
||||||
},
|
/>
|
||||||
]}
|
</div>
|
||||||
/>
|
),
|
||||||
</div>
|
{ label: "Loading Signage…", onRetry: settings.reload },
|
||||||
),
|
)}
|
||||||
{ label: "Loading Signage…", onRetry: settings.reload },
|
</DesktopOnly>
|
||||||
)}
|
</>
|
||||||
</DesktopOnly>
|
);
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-4
@@ -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";
|
import { type SignageApiFailure, SignageRequestError } from "./api.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,15 +21,76 @@ export interface Resource<Value> {
|
|||||||
reload(): void;
|
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>(
|
export function useResource<Value>(
|
||||||
load: () => Promise<Value>,
|
load: () => Promise<Value>,
|
||||||
dependencies: readonly unknown[],
|
dependencies: readonly unknown[],
|
||||||
|
/** Shares this resource with every other panel that names the same key. */
|
||||||
|
key?: string,
|
||||||
): Resource<Value> {
|
): 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 generation = useRef(0);
|
||||||
const loadRef = useRef(load);
|
const loadRef = useRef(load);
|
||||||
loadRef.current = 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(() => {
|
const run = useCallback(() => {
|
||||||
generation.current += 1;
|
generation.current += 1;
|
||||||
const token = generation.current;
|
const token = generation.current;
|
||||||
@@ -38,7 +99,13 @@ export function useResource<Value>(
|
|||||||
.then((value) => {
|
.then((value) => {
|
||||||
// A response from a request the panel has already moved on from must never overwrite a
|
// A response from a request the panel has already moved on from must never overwrite a
|
||||||
// newer one.
|
// 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 });
|
setState({ status: "ready", value });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -58,7 +125,7 @@ export function useResource<Value>(
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, []);
|
}, [cache, key]);
|
||||||
|
|
||||||
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
|
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
|
||||||
// change, which is the whole reason this takes one.
|
// change, which is the whole reason this takes one.
|
||||||
|
|||||||
+18
-20
@@ -14,24 +14,22 @@ import ownStyles from "./styles.css";
|
|||||||
* rather than bundled (ADR 0039).
|
* rather than bundled (ADR 0039).
|
||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.4",
|
version: "0.1.4",
|
||||||
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(
|
root.render(
|
||||||
<SignageDashboard
|
<SignageDashboard
|
||||||
context={context}
|
context={context}
|
||||||
copyText={async (value) => {
|
copyText={async (value) => {
|
||||||
if (globalThis.navigator.clipboard === undefined) {
|
if (globalThis.navigator.clipboard === undefined) {
|
||||||
throw new Error(
|
throw new Error("Clipboard access is not available in this browser.");
|
||||||
"Clipboard access is not available in this browser.",
|
}
|
||||||
);
|
await globalThis.navigator.clipboard.writeText(value);
|
||||||
}
|
}}
|
||||||
await globalThis.navigator.clipboard.writeText(value);
|
/>,
|
||||||
}}
|
);
|
||||||
/>,
|
return () => root.unmount();
|
||||||
);
|
},
|
||||||
return () => root.unmount();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
+503
-536
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import {
|
|||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
import { useAction, useResource } from "../hooks.js";
|
import { useAction, useResource } from "../hooks.js";
|
||||||
import type { ImageRecord, SettingsResponse } from "../types.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.
|
* The native image library and its upload queue.
|
||||||
@@ -28,7 +28,11 @@ export function ImagesPanel({
|
|||||||
readonly settings: SettingsResponse;
|
readonly settings: SettingsResponse;
|
||||||
readonly canManage: boolean;
|
readonly canManage: boolean;
|
||||||
}): ReactElement {
|
}): 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 [uploads, setUploads] = useState<readonly UploadItem[]>([]);
|
||||||
const queueRef = useRef<UploadQueue | undefined>(undefined);
|
const queueRef = useRef<UploadQueue | undefined>(undefined);
|
||||||
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
|
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
|
||||||
@@ -204,13 +208,15 @@ export function ImagesPanel({
|
|||||||
Rename
|
Rename
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<ConfirmButton
|
||||||
|
confirmLabel="Delete this image"
|
||||||
disabled={remove.pending}
|
disabled={remove.pending}
|
||||||
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
|
label="Delete"
|
||||||
variant="danger"
|
onConfirm={() =>
|
||||||
>
|
remove.run(() => api.send("DELETE", `images/${image.id}`))
|
||||||
Delete
|
}
|
||||||
</Button>
|
testId={`signage-image-delete-${image.id}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+326
-331
@@ -1,29 +1,16 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import {
|
import { ActionBar, Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
|
||||||
ActionBar,
|
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
EmptyState,
|
|
||||||
Field,
|
|
||||||
Panel,
|
|
||||||
Select,
|
|
||||||
} from "@ekklesia/ui";
|
|
||||||
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 {
|
import type {
|
||||||
ChannelGroupView,
|
ChannelGroupView,
|
||||||
PlaybackDetailView,
|
PlaybackDetailView,
|
||||||
SettingsResponse,
|
SettingsResponse,
|
||||||
TimelineSummary,
|
TimelineSummary,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { PreviewPanel } from "./PreviewPanel.js";
|
import { PreviewPanel } from "./PreviewPanel.js";
|
||||||
import {
|
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||||
FailureNote,
|
|
||||||
formatDuration,
|
|
||||||
formatInstant,
|
|
||||||
renderLoadable,
|
|
||||||
} from "./shared.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live transport for one channel group.
|
* Live transport for one channel group.
|
||||||
@@ -33,325 +20,333 @@ import {
|
|||||||
* button, and neither can produce a state the other did not ask for.
|
* button, and neither can produce a state the other did not ask for.
|
||||||
*/
|
*/
|
||||||
export function PlaybackPanel({
|
export function PlaybackPanel({
|
||||||
api,
|
api,
|
||||||
context,
|
context,
|
||||||
settings,
|
settings,
|
||||||
canControl,
|
canControl,
|
||||||
groupId,
|
groupId,
|
||||||
onGroupChange,
|
onGroupChange,
|
||||||
}: {
|
}: {
|
||||||
readonly api: SignageApi;
|
readonly api: SignageApi;
|
||||||
readonly context: ModuleUiHostContext;
|
readonly context: ModuleUiHostContext;
|
||||||
readonly settings: SettingsResponse;
|
readonly settings: SettingsResponse;
|
||||||
readonly canControl: boolean;
|
readonly canControl: boolean;
|
||||||
readonly groupId: string | undefined;
|
readonly groupId: string | undefined;
|
||||||
readonly onGroupChange: (groupId: string) => void;
|
readonly onGroupChange: (groupId: string) => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const groups = useResource(
|
const groups = useResource(
|
||||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||||
[],
|
[],
|
||||||
);
|
"channel-groups",
|
||||||
const timelines = useResource(
|
);
|
||||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
const timelines = useResource(
|
||||||
[],
|
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||||
);
|
[],
|
||||||
const detail = useResource(
|
"timelines",
|
||||||
() =>
|
);
|
||||||
groupId === undefined
|
const detail = useResource(
|
||||||
? Promise.resolve(undefined)
|
() =>
|
||||||
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
groupId === undefined
|
||||||
[groupId],
|
? Promise.resolve(undefined)
|
||||||
);
|
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
||||||
const command = useAction(() => {
|
[groupId],
|
||||||
detail.reload();
|
groupId === undefined ? undefined : `playback/${groupId}`,
|
||||||
groups.reload();
|
);
|
||||||
});
|
const command = useAction(() => {
|
||||||
const [activateId, setActivateId] = useState("");
|
detail.reload();
|
||||||
const previewChannelId =
|
groups.reload();
|
||||||
groups.state.status === "ready"
|
});
|
||||||
? (groups.state.value.groups
|
const [activateId, setActivateId] = useState("");
|
||||||
.find((group) => group.id === groupId)
|
const previewChannelId =
|
||||||
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
groups.state.status === "ready"
|
||||||
: undefined;
|
? (groups.state.value.groups
|
||||||
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
.find((group) => group.id === groupId)
|
||||||
useTick(1000);
|
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
||||||
|
: 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 {
|
function run(path: string, body?: unknown): void {
|
||||||
if (groupId === undefined) {
|
if (groupId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
|
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
|
||||||
}
|
}
|
||||||
|
|
||||||
function timelineLabel(timelineId: string): string {
|
function timelineLabel(timelineId: string): string {
|
||||||
if (timelines.state.status !== "ready") {
|
if (timelines.state.status !== "ready") {
|
||||||
return timelineId;
|
return timelineId;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
timelines.state.value.timelines.find(
|
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
|
||||||
(timeline) => timeline.id === timelineId,
|
timelineId
|
||||||
)?.name ?? timelineId
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signage-columns signage-playback-layout">
|
<div className="signage-columns signage-playback-layout">
|
||||||
<Panel
|
<Panel
|
||||||
actions={renderLoadable(
|
actions={renderLoadable(
|
||||||
groups.state,
|
groups.state,
|
||||||
(value) => (
|
(value) => (
|
||||||
<Field label="Channel group">
|
<Field label="Channel group">
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<Select
|
<Select
|
||||||
{...control}
|
{...control}
|
||||||
onChange={(event) => onGroupChange(event.target.value)}
|
onChange={(event) => onGroupChange(event.target.value)}
|
||||||
value={groupId ?? ""}
|
value={groupId ?? ""}
|
||||||
>
|
>
|
||||||
{value.groups.map((group) => (
|
{value.groups.map((group) => (
|
||||||
<option key={group.id} value={group.id}>
|
<option key={group.id} value={group.id}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
),
|
),
|
||||||
{ label: "Loading groups…" },
|
{ label: "Loading groups…" },
|
||||||
)}
|
)}
|
||||||
description="One clock for every channel in the group."
|
description="One clock for every channel in the group."
|
||||||
title="Playback"
|
title="Playback"
|
||||||
>
|
>
|
||||||
{groupId === undefined ? (
|
{groupId === undefined ? (
|
||||||
<EmptyState title="No channel group selected" />
|
<EmptyState title="No channel group selected" />
|
||||||
) : (
|
) : (
|
||||||
renderLoadable(
|
renderLoadable(
|
||||||
detail.state,
|
detail.state,
|
||||||
(value) =>
|
(value) =>
|
||||||
value === undefined ? (
|
value === undefined ? (
|
||||||
<EmptyState title="No channel group selected" />
|
<EmptyState title="No channel group selected" />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="signage-playback" data-testid="signage-playback">
|
||||||
className="signage-playback"
|
<div className="signage-playback__status">
|
||||||
data-testid="signage-playback"
|
{value.snapshot.activeTimelineId === null ? undefined : (
|
||||||
>
|
<Badge tone="success">
|
||||||
<div className="signage-playback__status">
|
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
|
||||||
{value.snapshot.activeTimelineId === null ? undefined : (
|
</Badge>
|
||||||
<Badge tone="success">
|
)}
|
||||||
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
|
<Badge
|
||||||
</Badge>
|
tone={
|
||||||
)}
|
value.snapshot.transport === "playing"
|
||||||
<Badge
|
? "success"
|
||||||
tone={
|
: value.snapshot.transport === "idle"
|
||||||
value.snapshot.transport === "playing"
|
? "warning"
|
||||||
? "success"
|
: "info"
|
||||||
: value.snapshot.transport === "idle"
|
}
|
||||||
? "warning"
|
>
|
||||||
: "info"
|
{value.snapshot.transport}
|
||||||
}
|
</Badge>
|
||||||
>
|
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
|
||||||
{value.snapshot.transport}
|
{value.current?.stale === true ? (
|
||||||
</Badge>
|
<Badge tone="warning">Stale</Badge>
|
||||||
{value.snapshot.blackout ? (
|
) : undefined}
|
||||||
<Badge tone="danger">Blackout</Badge>
|
{value.current?.withdrawn === true ? (
|
||||||
) : undefined}
|
<Badge tone="warning">Withdrawn</Badge>
|
||||||
{value.current?.stale === true ? (
|
) : undefined}
|
||||||
<Badge tone="warning">Stale</Badge>
|
{value.snapshot.pendingTimelineId === null ? undefined : (
|
||||||
) : undefined}
|
<Badge tone="info">Activation queued</Badge>
|
||||||
{value.current?.withdrawn === true ? (
|
)}
|
||||||
<Badge tone="warning">Withdrawn</Badge>
|
</div>
|
||||||
) : undefined}
|
|
||||||
{value.snapshot.pendingTimelineId === null ? undefined : (
|
|
||||||
<Badge tone="info">Activation queued</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{value.current === null ? (
|
{value.current === null ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="Nothing is currently resolvable. A display shows its safe slate."
|
description="Nothing is currently resolvable. A display shows its safe slate."
|
||||||
title="Nothing on screen"
|
title="Nothing on screen"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<section className="signage-playback__now">
|
<section className="signage-playback__now">
|
||||||
<h3>Now showing</h3>
|
<h3>Now showing</h3>
|
||||||
<dl
|
<dl className="signage-playback__current" data-testid="signage-current">
|
||||||
className="signage-playback__current"
|
<dt>Provider</dt>
|
||||||
data-testid="signage-current"
|
<dd>{value.current.providerId}</dd>
|
||||||
>
|
<dt>Item</dt>
|
||||||
<dt>Provider</dt>
|
<dd>{value.current.itemId}</dd>
|
||||||
<dd>{value.current.providerId}</dd>
|
<dt>Renderer</dt>
|
||||||
<dt>Item</dt>
|
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
||||||
<dd>{value.current.itemId}</dd>
|
<dt>Duration</dt>
|
||||||
<dt>Renderer</dt>
|
<dd>{formatDuration(value.current.durationMs)}</dd>
|
||||||
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
<dt>Remaining</dt>
|
||||||
<dt>Duration</dt>
|
<dd data-testid="signage-remaining">
|
||||||
<dd>{formatDuration(value.current.durationMs)}</dd>
|
{value.snapshot.remainingMs === null
|
||||||
<dt>Remaining</dt>
|
? "—"
|
||||||
<dd data-testid="signage-remaining">
|
: formatDuration(value.snapshot.remainingMs)}
|
||||||
{value.snapshot.remainingMs === null
|
</dd>
|
||||||
? "—"
|
<dt>Started</dt>
|
||||||
: formatDuration(value.snapshot.remainingMs)}
|
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
|
||||||
</dd>
|
</dl>
|
||||||
<dt>Started</dt>
|
</section>
|
||||||
<dd>
|
)}
|
||||||
{formatInstant(
|
|
||||||
value.current.startedAtMs,
|
|
||||||
settings.timezone,
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canControl ? (
|
{canControl ? (
|
||||||
<ActionBar
|
<ActionBar
|
||||||
className="signage-playback__controls"
|
className="signage-playback__controls"
|
||||||
data-testid="signage-controls"
|
data-testid="signage-controls"
|
||||||
label="Playback controls"
|
label="Playback controls"
|
||||||
>
|
>
|
||||||
<Button
|
<Button disabled={command.pending} onClick={() => run("previous")}>
|
||||||
disabled={command.pending}
|
Previous
|
||||||
onClick={() => run("previous")}
|
</Button>
|
||||||
>
|
{value.snapshot.transport === "paused" ? (
|
||||||
Previous
|
<Button
|
||||||
</Button>
|
disabled={command.pending}
|
||||||
{value.snapshot.transport === "paused" ? (
|
onClick={() => run("resume")}
|
||||||
<Button
|
variant="primary"
|
||||||
disabled={command.pending}
|
>
|
||||||
onClick={() => run("resume")}
|
Resume
|
||||||
variant="primary"
|
</Button>
|
||||||
>
|
) : (
|
||||||
Resume
|
<Button disabled={command.pending} onClick={() => run("pause")}>
|
||||||
</Button>
|
Pause
|
||||||
) : (
|
</Button>
|
||||||
<Button
|
)}
|
||||||
disabled={command.pending}
|
<Button disabled={command.pending} onClick={() => run("next")}>
|
||||||
onClick={() => run("pause")}
|
Next
|
||||||
>
|
</Button>
|
||||||
Pause
|
<Button
|
||||||
</Button>
|
disabled={command.pending}
|
||||||
)}
|
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
|
||||||
<Button
|
variant={value.snapshot.blackout ? "primary" : "secondary"}
|
||||||
disabled={command.pending}
|
>
|
||||||
onClick={() => run("next")}
|
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
||||||
>
|
</Button>
|
||||||
Next
|
{value.snapshot.staticAssetId === null ? undefined : (
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
disabled={command.pending}
|
||||||
disabled={command.pending}
|
onClick={() => run("static", { assetId: null })}
|
||||||
onClick={() =>
|
>
|
||||||
run("blackout", { on: !value.snapshot.blackout })
|
Release hold
|
||||||
}
|
</Button>
|
||||||
variant={
|
)}
|
||||||
value.snapshot.blackout ? "primary" : "secondary"
|
<Button disabled={command.pending} onClick={() => run("reload")}>
|
||||||
}
|
Reload content
|
||||||
>
|
</Button>
|
||||||
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
</ActionBar>
|
||||||
</Button>
|
) : undefined}
|
||||||
{value.snapshot.staticAssetId === null ? undefined : (
|
|
||||||
<Button
|
|
||||||
disabled={command.pending}
|
|
||||||
onClick={() => run("static", { assetId: null })}
|
|
||||||
>
|
|
||||||
Release hold
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
disabled={command.pending}
|
|
||||||
onClick={() => run("reload")}
|
|
||||||
>
|
|
||||||
Reload content
|
|
||||||
</Button>
|
|
||||||
</ActionBar>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{canControl ? (
|
{canControl ? (
|
||||||
<div className="signage-playback__activate">
|
<div className="signage-playback__activate">
|
||||||
{renderLoadable(
|
{renderLoadable(
|
||||||
timelines.state,
|
timelines.state,
|
||||||
(loaded) => (
|
(loaded) => (
|
||||||
<>
|
<>
|
||||||
<Field label="Activate timeline">
|
<Field label="Activate timeline">
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<Select
|
<Select
|
||||||
{...control}
|
{...control}
|
||||||
onChange={(event) =>
|
onChange={(event) => setActivateId(event.target.value)}
|
||||||
setActivateId(event.target.value)
|
value={activateId}
|
||||||
}
|
>
|
||||||
value={activateId}
|
<option value="">Choose a timeline…</option>
|
||||||
>
|
{loaded.timelines.map((timeline) => (
|
||||||
<option value="">Choose a timeline…</option>
|
<option key={timeline.id} value={timeline.id}>
|
||||||
{loaded.timelines.map((timeline) => (
|
{timeline.name}
|
||||||
<option
|
</option>
|
||||||
key={timeline.id}
|
))}
|
||||||
value={timeline.id}
|
</Select>
|
||||||
>
|
)}
|
||||||
{timeline.name}
|
</Field>
|
||||||
</option>
|
<Button
|
||||||
))}
|
disabled={command.pending || activateId === ""}
|
||||||
</Select>
|
onClick={() =>
|
||||||
)}
|
run("activate", {
|
||||||
</Field>
|
timelineId: activateId,
|
||||||
<Button
|
when: "now",
|
||||||
disabled={command.pending || activateId === ""}
|
})
|
||||||
onClick={() =>
|
}
|
||||||
run("activate", {
|
variant="primary"
|
||||||
timelineId: activateId,
|
>
|
||||||
when: "now",
|
Now
|
||||||
})
|
</Button>
|
||||||
}
|
<Button
|
||||||
variant="primary"
|
disabled={command.pending || activateId === ""}
|
||||||
>
|
onClick={() =>
|
||||||
Now
|
run("activate", {
|
||||||
</Button>
|
timelineId: activateId,
|
||||||
<Button
|
when: "next-boundary",
|
||||||
disabled={command.pending || activateId === ""}
|
})
|
||||||
onClick={() =>
|
}
|
||||||
run("activate", {
|
>
|
||||||
timelineId: activateId,
|
At next slot
|
||||||
when: "next-boundary",
|
</Button>
|
||||||
})
|
</>
|
||||||
}
|
),
|
||||||
>
|
{ label: "Loading timelines…" },
|
||||||
At next slot
|
)}
|
||||||
</Button>
|
</div>
|
||||||
</>
|
) : undefined}
|
||||||
),
|
|
||||||
{ label: "Loading timelines…" },
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{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">
|
<h4 className="signage-editor__subtitle">Recently played</h4>
|
||||||
Recently played
|
<ul className="ek-list">
|
||||||
</h4>
|
{value.history.slice(0, 8).map((entry) => (
|
||||||
<ul className="ek-list">
|
<li className="ek-list__item signage-row" key={entry.id}>
|
||||||
{value.history.slice(0, 8).map((entry) => (
|
<span className="signage-row__name">{entry.itemId}</span>
|
||||||
<li
|
<span className="signage-playback__history-meta">
|
||||||
className="ek-list__item signage-row"
|
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
||||||
key={entry.id}
|
</span>
|
||||||
>
|
</li>
|
||||||
<span className="signage-row__name">
|
))}
|
||||||
{entry.itemId}
|
</ul>
|
||||||
</span>
|
</div>
|
||||||
<span className="signage-playback__history-meta">
|
)}
|
||||||
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
</div>
|
||||||
</span>
|
),
|
||||||
</li>
|
{ label: "Loading playback…", onRetry: detail.reload },
|
||||||
))}
|
)
|
||||||
</ul>
|
)}
|
||||||
</div>
|
<FailureNote failure={command.failure} />
|
||||||
)}
|
</Panel>
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ label: "Loading playback…", onRetry: detail.reload },
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<FailureNote failure={command.failure} />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
|
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
|
||||||
</div>
|
</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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
|||||||
import { EmptyState, Panel } from "@ekklesia/ui";
|
import { EmptyState, Panel } from "@ekklesia/ui";
|
||||||
import { type ReactElement, useEffect, useRef } from "react";
|
import { type ReactElement, useEffect, useRef } from "react";
|
||||||
import { SignageSlate } from "../../browser/slate.js";
|
import { SignageSlate } from "../../browser/slate.js";
|
||||||
|
import { createMemoryMediaCache, createMemoryStore } from "../../browser/storage.js";
|
||||||
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
|
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
|
|
||||||
@@ -36,7 +37,16 @@ export function PreviewPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let stopped = false;
|
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;
|
let handle: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
async function pump(): Promise<void> {
|
async function pump(): Promise<void> {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
Panel,
|
Panel,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@ekklesia/ui";
|
} from "@ekklesia/ui";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
@@ -31,101 +31,88 @@ const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
|
|||||||
* exactly that, which is the difference between "this is broken" and "this needs an update".
|
* exactly that, which is the difference between "this is broken" and "this needs an update".
|
||||||
*/
|
*/
|
||||||
export function ProvidersPanel({
|
export function ProvidersPanel({
|
||||||
api,
|
api,
|
||||||
context,
|
context,
|
||||||
settings,
|
settings,
|
||||||
}: {
|
}: {
|
||||||
readonly api: SignageApi;
|
readonly api: SignageApi;
|
||||||
readonly context: ModuleUiHostContext;
|
readonly context: ModuleUiHostContext;
|
||||||
readonly settings: SettingsResponse;
|
readonly settings: SettingsResponse;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const providers = useResource(
|
const providers = useResource(
|
||||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||||
[],
|
[],
|
||||||
);
|
"providers",
|
||||||
const published = new Set(
|
);
|
||||||
context.contributions
|
const published = new Set(
|
||||||
.list(SLIDE_RENDERER_CAPABILITY)
|
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
|
||||||
.map((contribution) => contribution.id),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
actions={
|
actions={<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>}
|
||||||
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
|
description="Running content sources available to timelines."
|
||||||
}
|
title="Content providers"
|
||||||
description="Running content sources available to timelines."
|
>
|
||||||
title="Content providers"
|
{renderLoadable(
|
||||||
>
|
providers.state,
|
||||||
{renderLoadable(
|
(value) =>
|
||||||
providers.state,
|
value.providers.length === 0 ? (
|
||||||
(value) =>
|
<EmptyState
|
||||||
value.providers.length === 0 ? (
|
description="Signage works on its own with native images. Install a content module to add more."
|
||||||
<EmptyState
|
title="No content providers registered"
|
||||||
description="Signage works on its own with native images. Install a content module to add more."
|
/>
|
||||||
title="No content providers registered"
|
) : (
|
||||||
/>
|
<Table label="Provider health">
|
||||||
) : (
|
<TableHead>
|
||||||
<Table label="Provider health">
|
<TableRow>
|
||||||
<TableHead>
|
<TableCell header>Provider</TableCell>
|
||||||
<TableRow>
|
<TableCell header>Contract</TableCell>
|
||||||
<TableCell header>Provider</TableCell>
|
<TableCell header>Renderer</TableCell>
|
||||||
<TableCell header>Contract</TableCell>
|
<TableCell header>Health</TableCell>
|
||||||
<TableCell header>Renderer</TableCell>
|
<TableCell header>Last success</TableCell>
|
||||||
<TableCell header>Health</TableCell>
|
</TableRow>
|
||||||
<TableCell header>Last success</TableCell>
|
</TableHead>
|
||||||
</TableRow>
|
<TableBody>
|
||||||
</TableHead>
|
{value.providers.map((provider) => {
|
||||||
<TableBody>
|
const rendererPublished = published.has(provider.renderer.contributionId);
|
||||||
{value.providers.map((provider) => {
|
const isNative = provider.renderer.contributionId === "signage.native-image";
|
||||||
const rendererPublished = published.has(
|
return (
|
||||||
provider.renderer.contributionId,
|
<TableRow key={provider.providerId}>
|
||||||
);
|
<TableCell>
|
||||||
const isNative =
|
<span className="signage-provider__id">{provider.providerId}</span>
|
||||||
provider.renderer.contributionId === "signage.native-image";
|
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
||||||
return (
|
</TableCell>
|
||||||
<TableRow key={provider.providerId}>
|
<TableCell>{provider.contractVersion}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="signage-provider__id">
|
{isNative ? (
|
||||||
{provider.providerId}
|
<Badge tone="info">Built in</Badge>
|
||||||
</span>
|
) : rendererPublished ? (
|
||||||
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
<Badge tone="success">{provider.renderer.contributionId}</Badge>
|
||||||
</TableCell>
|
) : (
|
||||||
<TableCell>{provider.contractVersion}</TableCell>
|
<Badge tone="warning">Renderer not published</Badge>
|
||||||
<TableCell>
|
)}
|
||||||
{isNative ? (
|
</TableCell>
|
||||||
<Badge tone="info">Built in</Badge>
|
<TableCell>
|
||||||
) : rendererPublished ? (
|
{provider.consecutiveFailures === 0 ? (
|
||||||
<Badge tone="success">
|
<Badge tone="success">Healthy</Badge>
|
||||||
{provider.renderer.contributionId}
|
) : (
|
||||||
</Badge>
|
<Badge tone="danger">
|
||||||
) : (
|
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
||||||
<Badge tone="warning">Renderer not published</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{provider.consecutiveFailures === 0 ? (
|
{formatInstant(provider.lastSuccessAt, settings.timezone)}
|
||||||
<Badge tone="success">Healthy</Badge>
|
</TableCell>
|
||||||
) : (
|
</TableRow>
|
||||||
<Badge tone="danger">
|
);
|
||||||
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
})}
|
||||||
</Badge>
|
</TableBody>
|
||||||
)}
|
</Table>
|
||||||
</TableCell>
|
),
|
||||||
<TableCell>
|
{ label: "Loading providers…", onRetry: providers.reload },
|
||||||
{formatInstant(
|
)}
|
||||||
provider.lastSuccessAt,
|
</Panel>
|
||||||
settings.timezone,
|
);
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
),
|
|
||||||
{ label: "Loading providers…", onRetry: providers.reload },
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+470
-525
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui";
|
import { Badge, Button, ErrorState, LoadingState } from "@ekklesia/ui";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import { type ReactElement, type ReactNode, useEffect, useState } from "react";
|
||||||
import type { SignageApiFailure } from "../api.js";
|
import type { SignageApiFailure } from "../api.js";
|
||||||
import type { Loadable } from "../hooks.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 {
|
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
|
||||||
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+422
-414
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+103
-113
@@ -1,10 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
ModuleContributionDescriptor,
|
ModuleContributionDescriptor,
|
||||||
ModuleUiApiClient,
|
ModuleUiApiClient,
|
||||||
ModuleUiContributions,
|
ModuleUiContributions,
|
||||||
ModuleUiHostContext,
|
ModuleUiHostContext,
|
||||||
MountedModuleContribution,
|
MountedModuleContribution,
|
||||||
MountModuleContributionOptions,
|
MountModuleContributionOptions,
|
||||||
} from "@ekklesia/module-ui-sdk";
|
} from "@ekklesia/module-ui-sdk";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,132 +15,122 @@ import type {
|
|||||||
* simply never match.
|
* simply never match.
|
||||||
*/
|
*/
|
||||||
export interface FakeHostRoute {
|
export interface FakeHostRoute {
|
||||||
readonly method: string;
|
readonly method: string;
|
||||||
readonly path: string | RegExp;
|
readonly path: string | RegExp;
|
||||||
readonly respond: (request: {
|
readonly respond: (request: {
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly init: RequestInit | undefined;
|
readonly init: RequestInit | undefined;
|
||||||
}) => Response | Promise<Response>;
|
}) => Response | Promise<Response>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeHost {
|
export interface FakeHost {
|
||||||
readonly context: ModuleUiHostContext;
|
readonly context: ModuleUiHostContext;
|
||||||
readonly requests: { method: string; path: string }[];
|
readonly requests: { method: string; path: string }[];
|
||||||
/** Contributions this host published against Signage's renderer capability. */
|
/** Contributions this host published against Signage's renderer capability. */
|
||||||
readonly mounted: { contributionId: string; input: unknown }[];
|
readonly mounted: { contributionId: string; input: unknown }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jsonResponse(body: unknown, status = 200): Response {
|
export function jsonResponse(body: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorResponse(
|
export function errorResponse(code: string, message: string, status = 400): Response {
|
||||||
code: string,
|
return jsonResponse({ code, message }, status);
|
||||||
message: string,
|
|
||||||
status = 400,
|
|
||||||
): Response {
|
|
||||||
return jsonResponse({ code, message }, status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeHostOptions {
|
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 displayUrl?: string;
|
||||||
readonly principal?: ModuleUiHostContext["principal"];
|
readonly principal?: ModuleUiHostContext["principal"];
|
||||||
readonly mount?: (
|
readonly mount?: (
|
||||||
options: MountModuleContributionOptions,
|
options: MountModuleContributionOptions,
|
||||||
) => Promise<MountedModuleContribution> | MountedModuleContribution;
|
) => Promise<MountedModuleContribution> | MountedModuleContribution;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFakeHost(options: FakeHostOptions): FakeHost {
|
export function createFakeHost(options: FakeHostOptions): FakeHost {
|
||||||
const requests: { method: string; path: string }[] = [];
|
const requests: { method: string; path: string }[] = [];
|
||||||
const mounted: { contributionId: string; input: unknown }[] = [];
|
const mounted: { contributionId: string; input: unknown }[] = [];
|
||||||
|
|
||||||
const api: ModuleUiApiClient = {
|
const api: ModuleUiApiClient = {
|
||||||
async request(path, init) {
|
async request(path, init) {
|
||||||
const method = (init?.method ?? "GET").toUpperCase();
|
const method = (init?.method ?? "GET").toUpperCase();
|
||||||
const normalized = path.replace(/^\//u, "");
|
const normalized = path.replace(/^\//u, "");
|
||||||
requests.push({ method, path: normalized });
|
requests.push({ method, path: normalized });
|
||||||
for (const route of options.routes) {
|
for (const route of options.routes) {
|
||||||
const matches =
|
const matches =
|
||||||
typeof route.path === "string"
|
typeof route.path === "string"
|
||||||
? route.path === normalized.split("?")[0]
|
? route.path === normalized.split("?")[0]
|
||||||
: route.path.test(normalized);
|
: route.path.test(normalized);
|
||||||
if (route.method === method && matches) {
|
if (route.method === method && matches) {
|
||||||
return route.respond({ init, path: normalized });
|
return route.respond({ init, path: normalized });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errorResponse(
|
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
|
||||||
"SIGNAGE_NOT_FOUND",
|
},
|
||||||
"The requested resource was not found.",
|
};
|
||||||
404,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const contributions: ModuleUiContributions = {
|
const contributions: ModuleUiContributions = {
|
||||||
list: (capabilityId) =>
|
list: (capabilityId) =>
|
||||||
capabilityId === "signage.slide-renderer"
|
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
|
||||||
? (options.contributions ?? [])
|
async mount(mountOptions) {
|
||||||
: [],
|
mounted.push({
|
||||||
async mount(mountOptions) {
|
contributionId: mountOptions.contributionId,
|
||||||
mounted.push({
|
input: mountOptions.input,
|
||||||
contributionId: mountOptions.contributionId,
|
});
|
||||||
input: mountOptions.input,
|
if (options.mount) {
|
||||||
});
|
return options.mount(mountOptions);
|
||||||
if (options.mount) {
|
}
|
||||||
return options.mount(mountOptions);
|
return {
|
||||||
}
|
contributionId: mountOptions.contributionId,
|
||||||
return {
|
mounted: true,
|
||||||
contributionId: mountOptions.contributionId,
|
unmount: () => Promise.resolve(),
|
||||||
mounted: true,
|
update: () => Promise.resolve(),
|
||||||
unmount: () => Promise.resolve(),
|
version: "1.0.0",
|
||||||
update: () => Promise.resolve(),
|
};
|
||||||
version: "1.0.0",
|
},
|
||||||
};
|
};
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context: {
|
context: {
|
||||||
api,
|
api,
|
||||||
contributions,
|
contributions,
|
||||||
display: {
|
display: {
|
||||||
url: options.displayUrl ?? "https://api.example.test/display/signage",
|
url: options.displayUrl ?? "https://api.example.test/display/signage",
|
||||||
},
|
},
|
||||||
moduleId: "signage",
|
moduleId: "signage",
|
||||||
permissions: {
|
permissions: {
|
||||||
has: (permission) => options.permissions.includes(permission),
|
has: (permission) => options.permissions.includes(permission),
|
||||||
},
|
},
|
||||||
principal: options.principal ?? { kind: "anonymous" },
|
principal: options.principal ?? { kind: "anonymous" },
|
||||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||||
},
|
},
|
||||||
mounted,
|
mounted,
|
||||||
requests,
|
requests,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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.4",
|
moduleVersion: "0.1.4",
|
||||||
nativeImageDurationMs: 7000,
|
nativeImageDurationMs: 7000,
|
||||||
nativePresentation: {
|
nativePresentation: {
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
backgroundDarkened: false,
|
backgroundDarkened: false,
|
||||||
backgroundMode: "blurred",
|
backgroundMode: "blurred",
|
||||||
blurPixels: 20,
|
blurPixels: 20,
|
||||||
},
|
},
|
||||||
rendererInputVersion: "1.0.0",
|
rendererInputVersion: "1.0.0",
|
||||||
timezone: "America/Chicago",
|
timezone: "America/Chicago",
|
||||||
uploads: {
|
uploads: {
|
||||||
batchMaxFiles: 20,
|
batchMaxFiles: 20,
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
maxBytes: 26_214_400,
|
maxBytes: 26_214_400,
|
||||||
maxPixels: 40_000_000,
|
maxPixels: 40_000_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
database: {
|
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,
|
} as unknown as ModuleDrizzleDatabase,
|
||||||
positions,
|
positions,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user