fix(ui): keep Signage state consistent across tabs, and confirm destructive actions
Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
67d89f1020
commit
7c17282616
@@ -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