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