Compare commits
12
Commits
92bef4c7aa
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e418bf941 | ||
|
|
a68d7ebaee | ||
|
|
1bf89a3942 | ||
|
|
ac2b8ded93 | ||
|
|
af0f0adadc | ||
|
|
36c1a72363 | ||
|
|
7999582460 | ||
|
|
218e05e71e | ||
|
|
afac1bf8e7 | ||
|
|
49d056f744 | ||
|
|
aa8cb1c96f | ||
|
|
5760059ee3 |
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.1.0 is feature-complete for its declared scope and unreleased. What exists today:
|
Version 0.1.1 is feature-complete for its declared scope and unreleased. What exists today:
|
||||||
|
|
||||||
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
|
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
|
||||||
one logical storage bucket.
|
one logical storage bucket.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"id": "signage",
|
"id": "signage",
|
||||||
"name": "Signage",
|
"name": "Signage",
|
||||||
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.1.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.1.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/signage",
|
"name": "@ekklesia/signage",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
|
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
|
|||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.0",
|
version: "0.1.1",
|
||||||
styles: displayStyles,
|
styles: displayStyles,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|||||||
@@ -91,11 +91,20 @@ export function registerPlaybackRoutes(
|
|||||||
const groupId = requireUuidParam(context, "groupId");
|
const groupId = requireUuidParam(context, "groupId");
|
||||||
const store = createSignageStore(context.services.database);
|
const store = createSignageStore(context.services.database);
|
||||||
const groups = await store.listChannelGroups();
|
const groups = await store.listChannelGroups();
|
||||||
|
const group = groups.find((candidate) => candidate.group.id === groupId);
|
||||||
|
if (!group) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
if (groups.length <= 1) {
|
if (groups.length <= 1) {
|
||||||
// An installation always has somewhere for a viewer to land.
|
// An installation always has somewhere for a viewer to land.
|
||||||
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
|
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
|
||||||
}
|
}
|
||||||
await store.deleteChannelGroup(groupId);
|
const fallbackGroup = groups.find((candidate) => candidate.group.id !== groupId);
|
||||||
|
const fallbackChannel = fallbackGroup?.channels.find((channel) => channel.isDefault);
|
||||||
|
if (!fallbackChannel) {
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-fallback-channel" });
|
||||||
|
}
|
||||||
|
await store.deleteChannelGroup(groupId, fallbackChannel.id);
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function createSignageModuleRuntime(
|
|||||||
|
|
||||||
const definition = defineModuleRuntime({
|
const definition = defineModuleRuntime({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.0",
|
version: "0.1.1",
|
||||||
|
|
||||||
register({ capabilities, config, http, realtime }) {
|
register({ capabilities, config, http, realtime }) {
|
||||||
const settings = readSignageSettings(config);
|
const settings = readSignageSettings(config);
|
||||||
@@ -188,7 +188,7 @@ export function createSignageModuleRuntime(
|
|||||||
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
||||||
return Response.json({
|
return Response.json({
|
||||||
crossfadeMs: current.crossfadeMs,
|
crossfadeMs: current.crossfadeMs,
|
||||||
moduleVersion: "0.1.0",
|
moduleVersion: "0.1.1",
|
||||||
nativeImageDurationMs: current.nativeImageDurationMs,
|
nativeImageDurationMs: current.nativeImageDurationMs,
|
||||||
nativePresentation: current.nativePresentation,
|
nativePresentation: current.nativePresentation,
|
||||||
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
playbackStates,
|
playbackStates,
|
||||||
selections,
|
selections,
|
||||||
selectionVariants,
|
selectionVariants,
|
||||||
|
viewers,
|
||||||
} from "../../schema/tables.js";
|
} from "../../schema/tables.js";
|
||||||
import {
|
import {
|
||||||
type NewStoredSelection,
|
type NewStoredSelection,
|
||||||
@@ -176,8 +177,21 @@ export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackSt
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteChannelGroup(groupId: string): Promise<void> {
|
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
|
||||||
await database.delete(channelGroups).where(eq(channelGroups.id, groupId));
|
await database.transaction(async (transaction) => {
|
||||||
|
const removedChannels = await transaction
|
||||||
|
.select({ id: channels.id })
|
||||||
|
.from(channels)
|
||||||
|
.where(eq(channels.groupId, groupId));
|
||||||
|
const removedChannelIds = removedChannels.map((channel) => channel.id);
|
||||||
|
if (removedChannelIds.length > 0) {
|
||||||
|
await transaction
|
||||||
|
.update(viewers)
|
||||||
|
.set({ channelId: fallbackChannelId, updatedAt: new Date() })
|
||||||
|
.where(inArray(viewers.channelId, removedChannelIds));
|
||||||
|
}
|
||||||
|
await transaction.delete(channelGroups).where(eq(channelGroups.id, groupId));
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
|
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
|
||||||
|
|||||||
@@ -112,20 +112,21 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
|
|||||||
/**
|
/**
|
||||||
* Rewrites `position` for one timeline from an explicit order.
|
* Rewrites `position` for one timeline from an explicit order.
|
||||||
*
|
*
|
||||||
* Positions are written in two passes, through a temporary negative range, because the unique
|
* Positions are written in two passes, through a temporary high range, because the unique
|
||||||
* `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while
|
* `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while
|
||||||
* another still holds 1 would collide. Negative positions are outside every real value, so the
|
* another still holds 1 would collide. Temporary positions sit beyond the final order while
|
||||||
* intermediate state cannot be mistaken for an order.
|
* remaining inside the database's non-negative constraint.
|
||||||
*/
|
*/
|
||||||
async function renumber(
|
async function renumber(
|
||||||
transaction: ModuleDatabase,
|
transaction: ModuleDatabase,
|
||||||
timelineId: string,
|
timelineId: string,
|
||||||
orderedSlotIds: readonly string[],
|
orderedSlotIds: readonly string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const temporaryOffset = orderedSlotIds.length + 1;
|
||||||
for (const [index, slotId] of orderedSlotIds.entries()) {
|
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||||
await transaction
|
await transaction
|
||||||
.update(timelineSlots)
|
.update(timelineSlots)
|
||||||
.set({ position: -1 - index })
|
.set({ position: temporaryOffset + index })
|
||||||
.where(eq(timelineSlots.id, slotId));
|
.where(eq(timelineSlots.id, slotId));
|
||||||
}
|
}
|
||||||
for (const [index, slotId] of orderedSlotIds.entries()) {
|
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||||
@@ -175,7 +176,7 @@ export function createTimelineStore(database: ModuleDatabase): SignageTimelineSt
|
|||||||
position === undefined
|
position === undefined
|
||||||
? existing.length
|
? existing.length
|
||||||
: Math.max(0, Math.min(position, existing.length));
|
: Math.max(0, Math.min(position, existing.length));
|
||||||
await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000));
|
await transaction.insert(timelineSlots).values(slotValues(slot, existing.length + 1));
|
||||||
const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
|
const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
|
||||||
await renumber(transaction, slot.timelineId, next);
|
await renumber(transaction, slot.timelineId, next);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -351,7 +351,9 @@ export interface SignagePlaybackStore {
|
|||||||
readonly channelId: string;
|
readonly channelId: string;
|
||||||
readonly channelName: string;
|
readonly channelName: string;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
deleteChannelGroup(groupId: string): Promise<void>;
|
/** Deletes a group and moves every assigned viewer to the supplied fallback channel atomically,
|
||||||
|
* so removing a group can never leave a display unassigned. */
|
||||||
|
deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void>;
|
||||||
addChannel(channel: StoredChannel): Promise<void>;
|
addChannel(channel: StoredChannel): Promise<void>;
|
||||||
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
|
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
|
||||||
removeChannel(channelId: string): Promise<void>;
|
removeChannel(channelId: string): Promise<void>;
|
||||||
|
|||||||
+165
-111
@@ -1,5 +1,5 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui";
|
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
|
||||||
import { type ReactElement, useMemo, useState } from "react";
|
import { type ReactElement, useMemo, useState } from "react";
|
||||||
import { DesktopOnly } from "../browser/desktop-gate.js";
|
import { DesktopOnly } from "../browser/desktop-gate.js";
|
||||||
import { createSignageApi } from "./api.js";
|
import { createSignageApi } from "./api.js";
|
||||||
@@ -23,120 +23,174 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
|||||||
* 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({
|
||||||
context,
|
context,
|
||||||
|
copyText,
|
||||||
}: {
|
}: {
|
||||||
readonly context: ModuleUiHostContext;
|
readonly context: ModuleUiHostContext;
|
||||||
|
readonly copyText: (value: string) => Promise<void>;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
||||||
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
||||||
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
// 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.
|
// request it will certainly refuse is noise in a log rather than security.
|
||||||
const mayView = context.permissions.has("signage.view");
|
const mayView = context.permissions.has("signage.view");
|
||||||
const settings = useResource(
|
const settings = useResource(
|
||||||
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
|
() =>
|
||||||
[mayView],
|
mayView
|
||||||
);
|
? api.get<SettingsResponse>("settings")
|
||||||
const groups = useResource(
|
: Promise.resolve(undefined),
|
||||||
() =>
|
[mayView],
|
||||||
mayView
|
);
|
||||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
const groups = useResource(
|
||||||
: Promise.resolve(undefined),
|
() =>
|
||||||
[mayView],
|
mayView
|
||||||
);
|
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||||
const [tab, setTab] = useState("playback");
|
: Promise.resolve(undefined),
|
||||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
[mayView],
|
||||||
|
);
|
||||||
|
const [tab, setTab] = useState("playback");
|
||||||
|
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||||
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
|
const mayManageTimelines = context.permissions.has(
|
||||||
const mayControl = context.permissions.has("signage.playback.control");
|
"signage.timelines.manage",
|
||||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
);
|
||||||
|
const mayControl = context.permissions.has("signage.playback.control");
|
||||||
|
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||||
|
|
||||||
if (!mayView) {
|
if (!mayView) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="Ask an administrator for the signage.view permission."
|
description="Ask an administrator for the signage.view permission."
|
||||||
title="No access to Signage"
|
title="No access to Signage"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedGroupId =
|
const selectedGroupId =
|
||||||
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
|
groupId ??
|
||||||
|
(groups.state.status === "ready"
|
||||||
|
? groups.state.value?.groups[0]?.id
|
||||||
|
: undefined);
|
||||||
|
const selectedGroup =
|
||||||
|
groups.state.status === "ready"
|
||||||
|
? groups.state.value?.groups.find(
|
||||||
|
(candidate) => candidate.id === selectedGroupId,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EkklesiaUiStyles />
|
<EkklesiaUiStyles />
|
||||||
<DesktopOnly>
|
<DesktopOnly>
|
||||||
{renderLoadable(
|
<PageHeader
|
||||||
settings.state,
|
description="Plan visual content, operate playback, and keep every screen connected."
|
||||||
(loaded) =>
|
eyebrow="Media"
|
||||||
loaded === undefined ? undefined : (
|
title="Signage"
|
||||||
<Tabs
|
/>
|
||||||
label="Signage sections"
|
{renderLoadable(
|
||||||
onSelect={setTab}
|
settings.state,
|
||||||
selectedId={tab}
|
(loaded) =>
|
||||||
tabs={[
|
loaded === undefined ? undefined : (
|
||||||
{
|
<div className="signage-workspace">
|
||||||
content: (
|
<Tabs
|
||||||
<PlaybackPanel
|
label="Signage sections"
|
||||||
api={api}
|
onSelect={setTab}
|
||||||
canControl={mayControl}
|
selectedId={tab}
|
||||||
context={context}
|
tabs={[
|
||||||
groupId={selectedGroupId}
|
{
|
||||||
onGroupChange={setGroupId}
|
content: (
|
||||||
settings={loaded}
|
<PlaybackPanel
|
||||||
/>
|
api={api}
|
||||||
),
|
canControl={mayControl}
|
||||||
id: "playback",
|
context={context}
|
||||||
label: "Playback",
|
groupId={selectedGroupId}
|
||||||
},
|
onGroupChange={setGroupId}
|
||||||
{
|
settings={loaded}
|
||||||
content: (
|
/>
|
||||||
<TimelinesPanel
|
),
|
||||||
api={api}
|
id: "playback",
|
||||||
canManage={mayManageTimelines}
|
label: "Playback",
|
||||||
onActivate={
|
},
|
||||||
mayControl && selectedGroupId !== undefined
|
{
|
||||||
? (timelineId) => {
|
content: (
|
||||||
void api
|
<TimelinesPanel
|
||||||
.send("POST", `playback/${selectedGroupId}/activate`, {
|
{...(selectedGroup
|
||||||
timelineId,
|
? {
|
||||||
when: "now",
|
activeGroupName: selectedGroup.name,
|
||||||
})
|
activeTimelineId:
|
||||||
.then(() => setTab("playback"))
|
selectedGroup.playback.activeTimelineId,
|
||||||
.catch(() => setTab("playback"));
|
}
|
||||||
}
|
: {})}
|
||||||
: undefined
|
api={api}
|
||||||
}
|
canManage={mayManageTimelines}
|
||||||
settings={loaded}
|
onActivate={
|
||||||
/>
|
mayControl && selectedGroupId !== undefined
|
||||||
),
|
? async (timelineId) => {
|
||||||
id: "timelines",
|
await api.send(
|
||||||
label: "Timelines",
|
"POST",
|
||||||
},
|
`playback/${selectedGroupId}/activate`,
|
||||||
{
|
{
|
||||||
content: (
|
timelineId,
|
||||||
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
when: "now",
|
||||||
),
|
},
|
||||||
id: "images",
|
);
|
||||||
label: "Images",
|
groups.reload();
|
||||||
},
|
setTab("playback");
|
||||||
{
|
}
|
||||||
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
|
: undefined
|
||||||
id: "displays",
|
}
|
||||||
label: "Displays",
|
settings={loaded}
|
||||||
},
|
/>
|
||||||
{
|
),
|
||||||
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
|
id: "timelines",
|
||||||
id: "providers",
|
label: "Timelines",
|
||||||
label: "Providers",
|
},
|
||||||
},
|
{
|
||||||
]}
|
content: (
|
||||||
/>
|
<ImagesPanel
|
||||||
),
|
api={api}
|
||||||
{ label: "Loading Signage…", onRetry: settings.reload },
|
canManage={mayManageAssets}
|
||||||
)}
|
settings={loaded}
|
||||||
</DesktopOnly>
|
/>
|
||||||
</>
|
),
|
||||||
);
|
id: "images",
|
||||||
|
label: "Images",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: (
|
||||||
|
<DisplaysPanel
|
||||||
|
api={api}
|
||||||
|
canManage={mayManageDisplays}
|
||||||
|
copyText={copyText}
|
||||||
|
displaySetupUrl={context.display?.url}
|
||||||
|
onGroupsChanged={() => {
|
||||||
|
setGroupId(undefined);
|
||||||
|
groups.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: "displays",
|
||||||
|
label: "Displays",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: (
|
||||||
|
<ProvidersPanel
|
||||||
|
api={api}
|
||||||
|
context={context}
|
||||||
|
settings={loaded}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: "providers",
|
||||||
|
label: "Providers",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ label: "Loading Signage…", onRetry: settings.reload },
|
||||||
|
)}
|
||||||
|
</DesktopOnly>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-8
@@ -14,12 +14,24 @@ 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.0",
|
version: "0.1.1",
|
||||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
root.render(<SignageDashboard context={context} />);
|
root.render(
|
||||||
return () => root.unmount();
|
<SignageDashboard
|
||||||
},
|
context={context}
|
||||||
|
copyText={async (value) => {
|
||||||
|
if (globalThis.navigator.clipboard === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"Clipboard access is not available in this browser.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await globalThis.navigator.clipboard.writeText(value);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return () => root.unmount();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+546
-272
@@ -1,288 +1,562 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
EmptyState,
|
Field,
|
||||||
Field,
|
InlineNotice,
|
||||||
Panel,
|
Panel,
|
||||||
Select,
|
Select,
|
||||||
Table,
|
TextInput,
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextInput,
|
|
||||||
} from "@ekklesia/ui";
|
} from "@ekklesia/ui";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
import { useAction, useResource, useTick } from "../hooks.js";
|
import { useAction, useResource, useTick } from "../hooks.js";
|
||||||
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
||||||
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
|
import {
|
||||||
|
FailureNote,
|
||||||
|
formatSince,
|
||||||
|
OnlineBadge,
|
||||||
|
renderLoadable,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
|
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||||
|
|
||||||
|
function DisplayRow({
|
||||||
|
canManage,
|
||||||
|
groups,
|
||||||
|
nowMs,
|
||||||
|
onChannelChange,
|
||||||
|
onNameChange,
|
||||||
|
viewer,
|
||||||
|
}: {
|
||||||
|
readonly canManage: boolean;
|
||||||
|
readonly groups: readonly ChannelGroupView[];
|
||||||
|
readonly nowMs: number;
|
||||||
|
readonly onChannelChange: (channelId: string) => void;
|
||||||
|
readonly onNameChange: (name: string) => void;
|
||||||
|
readonly viewer: ViewerHealthView;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="signage-display-row"
|
||||||
|
data-testid={`signage-display-${viewer.id}`}
|
||||||
|
>
|
||||||
|
<div className="signage-display-row__identity">
|
||||||
|
{canManage ? (
|
||||||
|
<TextInput
|
||||||
|
aria-label={`Name for ${viewer.name}`}
|
||||||
|
defaultValue={viewer.name}
|
||||||
|
onBlur={(event) =>
|
||||||
|
event.target.value !== viewer.name &&
|
||||||
|
onNameChange(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<strong>{viewer.name}</strong>
|
||||||
|
)}
|
||||||
|
<OnlineBadge online={viewer.online} />
|
||||||
|
</div>
|
||||||
|
<div className="signage-display-row__assignment">
|
||||||
|
{canManage ? (
|
||||||
|
<Field label="Audience channel">
|
||||||
|
{(control) => (
|
||||||
|
<Select
|
||||||
|
{...control}
|
||||||
|
aria-label={`Channel for ${viewer.name}`}
|
||||||
|
onChange={(event) => onChannelChange(event.target.value)}
|
||||||
|
value={viewer.channelId ?? ""}
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<optgroup key={group.id} label={group.name}>
|
||||||
|
{group.channels.map((channel) => (
|
||||||
|
<option key={channel.id} value={channel.id}>
|
||||||
|
{channel.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
<div className="signage-display-row__health">
|
||||||
|
<span>{`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`}</span>
|
||||||
|
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
||||||
|
{viewer.offlineReady
|
||||||
|
? `${viewer.cachedAssetCount} cached`
|
||||||
|
: "Not offline ready"}
|
||||||
|
</Badge>
|
||||||
|
{viewer.lastError === null ? undefined : (
|
||||||
|
<span
|
||||||
|
className="signage-display-row__error"
|
||||||
|
title={viewer.lastError.detail ?? undefined}
|
||||||
|
>
|
||||||
|
{viewer.lastError.code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel groups, audience channels, and the display roster.
|
* Channel groups, audience channels, and displays, presented in the same hierarchy they use at run
|
||||||
*
|
* time: one group clock, one audience variant per channel, then the screens assigned to it.
|
||||||
* A church starts with one group and one default channel. `in-person` and `online` are created here,
|
|
||||||
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
|
|
||||||
* understand and ignore (ADR 0004).
|
|
||||||
*/
|
*/
|
||||||
export function DisplaysPanel({
|
export function DisplaysPanel({
|
||||||
api,
|
api,
|
||||||
canManage,
|
canManage,
|
||||||
|
copyText,
|
||||||
|
displaySetupUrl,
|
||||||
|
onGroupsChanged,
|
||||||
}: {
|
}: {
|
||||||
readonly api: SignageApi;
|
readonly api: SignageApi;
|
||||||
readonly canManage: boolean;
|
readonly canManage: boolean;
|
||||||
|
readonly copyText: (value: string) => Promise<void>;
|
||||||
|
readonly displaySetupUrl: string | undefined;
|
||||||
|
readonly onGroupsChanged?: () => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const groups = useResource(
|
const groups = useResource(
|
||||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const viewers = useResource(
|
const viewers = useResource(
|
||||||
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const edit = useAction(() => {
|
const edit = useAction(() => {
|
||||||
groups.reload();
|
groups.reload();
|
||||||
viewers.reload();
|
viewers.reload();
|
||||||
});
|
onGroupsChanged?.();
|
||||||
const [groupName, setGroupName] = useState("");
|
});
|
||||||
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
|
const viewerEdit = useAction(viewers.reload);
|
||||||
groupId: "",
|
const [groupName, setGroupName] = useState("");
|
||||||
key: "",
|
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">(
|
||||||
name: "",
|
"idle",
|
||||||
});
|
);
|
||||||
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
|
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(
|
||||||
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
|
undefined,
|
||||||
useTick(5000);
|
);
|
||||||
const nowMs = Date.now();
|
const [channelDraft, setChannelDraft] = useState<{
|
||||||
|
groupId: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}>({
|
||||||
|
groupId: "",
|
||||||
|
key: "",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
useTick(5000);
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const readyViewers =
|
||||||
|
viewers.state.status === "ready" ? viewers.state.value.viewers : [];
|
||||||
|
|
||||||
const channels =
|
const updateViewer = (
|
||||||
groups.state.status === "ready"
|
viewerId: string,
|
||||||
? groups.state.value.groups.flatMap((group) =>
|
changes: Record<string, string>,
|
||||||
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
|
): void => {
|
||||||
)
|
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
|
||||||
: [];
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signage-columns">
|
<div className="signage-columns">
|
||||||
<Panel
|
<Panel
|
||||||
actions={
|
actions={
|
||||||
canManage ? (
|
displaySetupUrl === undefined ? undefined : (
|
||||||
<div className="signage-inline-form">
|
<div className="signage-pairing-actions">
|
||||||
<Field label="New channel group">
|
<a
|
||||||
{(control) => (
|
className="ek-button ek-focusable ek-button--primary"
|
||||||
<TextInput
|
href={displaySetupUrl}
|
||||||
{...control}
|
rel="noopener noreferrer"
|
||||||
onChange={(event) => setGroupName(event.target.value)}
|
target="_blank"
|
||||||
placeholder="Nursery hallway"
|
>
|
||||||
value={groupName}
|
Open display setup
|
||||||
/>
|
</a>
|
||||||
)}
|
<Button
|
||||||
</Field>
|
onClick={() => {
|
||||||
<Button
|
setCopyStatus("idle");
|
||||||
disabled={edit.pending || groupName.trim().length === 0}
|
void copyText(displaySetupUrl).then(
|
||||||
onClick={() =>
|
() => setCopyStatus("copied"),
|
||||||
edit.run(async () => {
|
() => setCopyStatus("failed"),
|
||||||
await api.send("POST", "channel-groups", { name: groupName.trim() });
|
);
|
||||||
setGroupName("");
|
}}
|
||||||
})
|
>
|
||||||
}
|
Copy URL
|
||||||
variant="primary"
|
</Button>
|
||||||
>
|
</div>
|
||||||
Create
|
)
|
||||||
</Button>
|
}
|
||||||
</div>
|
description="Connect a screen in three short steps."
|
||||||
) : undefined
|
title="Pair a display"
|
||||||
}
|
>
|
||||||
description="A group is one clock. An audience channel is one rendering of it."
|
<ol
|
||||||
title="Channel groups"
|
className="signage-pairing-steps"
|
||||||
>
|
aria-label="Display pairing steps"
|
||||||
{renderLoadable(
|
>
|
||||||
groups.state,
|
<li>
|
||||||
(value) => (
|
<span className="signage-pairing-steps__number">1</span>
|
||||||
<div className="signage-groups" data-testid="signage-groups">
|
<span>
|
||||||
{value.groups.map((group) => (
|
Open display setup on the screen that will show signage.
|
||||||
<div className="signage-group" key={group.id}>
|
</span>
|
||||||
<div className="signage-group__header">
|
</li>
|
||||||
<h4 className="signage-editor__subtitle">{group.name}</h4>
|
<li>
|
||||||
<Badge tone="info">{group.playback.transport}</Badge>
|
<span className="signage-pairing-steps__number">2</span>
|
||||||
</div>
|
<span>
|
||||||
<ul className="ek-list">
|
Enter its six-digit code under Display devices in the Dashboard
|
||||||
{group.channels.map((channel) => (
|
sidebar.
|
||||||
<li className="ek-list__item signage-row" key={channel.id}>
|
</span>
|
||||||
<span className="signage-row__name">{channel.name}</span>
|
</li>
|
||||||
<Badge tone={channel.isDefault ? "success" : "neutral"}>
|
<li>
|
||||||
{channel.key}
|
<span className="signage-pairing-steps__number">3</span>
|
||||||
</Badge>
|
<span>Name the display and choose its audience channel below.</span>
|
||||||
{canManage && !channel.isDefault ? (
|
</li>
|
||||||
<Button
|
</ol>
|
||||||
disabled={edit.pending}
|
{displaySetupUrl === undefined ? (
|
||||||
onClick={() =>
|
<InlineNotice tone="warning" title="Display setup unavailable">
|
||||||
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
|
This host did not provide a display setup address.
|
||||||
}
|
</InlineNotice>
|
||||||
variant="danger"
|
) : undefined}
|
||||||
>
|
<span className="signage-copy-status" aria-live="polite">
|
||||||
Remove
|
{copyStatus === "copied"
|
||||||
</Button>
|
? "Display setup URL copied."
|
||||||
) : undefined}
|
: copyStatus === "failed"
|
||||||
</li>
|
? "Could not copy the URL. Copy it from the newly opened setup page."
|
||||||
))}
|
: ""}
|
||||||
</ul>
|
</span>
|
||||||
{canManage ? (
|
{viewers.state.status === "ready" &&
|
||||||
<div className="signage-inline-form" data-testid="signage-add-channel">
|
viewers.state.value.viewers.length === 0 ? (
|
||||||
<Field hint="Lowercase, e.g. online" label="Audience key">
|
<div className="signage-display-empty">
|
||||||
{(control) => (
|
<strong>No displays paired yet</strong>
|
||||||
<TextInput
|
<span>
|
||||||
{...control}
|
A newly approved screen appears below in its assigned channel.
|
||||||
onChange={(event) =>
|
</span>
|
||||||
setChannelDraft({
|
</div>
|
||||||
groupId: group.id,
|
) : undefined}
|
||||||
key: event.target.value,
|
</Panel>
|
||||||
name: channelDraft.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label="Channel name">
|
|
||||||
{(control) => (
|
|
||||||
<TextInput
|
|
||||||
{...control}
|
|
||||||
onChange={(event) =>
|
|
||||||
setChannelDraft({
|
|
||||||
groupId: group.id,
|
|
||||||
key: channelDraft.key,
|
|
||||||
name: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Button
|
|
||||||
disabled={
|
|
||||||
edit.pending ||
|
|
||||||
channelDraft.groupId !== group.id ||
|
|
||||||
channelDraft.key.trim() === "" ||
|
|
||||||
channelDraft.name.trim() === ""
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
edit.run(async () => {
|
|
||||||
await api.send("POST", `channel-groups/${group.id}/channels`, {
|
|
||||||
key: channelDraft.key.trim(),
|
|
||||||
name: channelDraft.name.trim(),
|
|
||||||
});
|
|
||||||
setChannelDraft({ groupId: "", key: "", name: "" });
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Add audience channel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ label: "Loading channel groups…", onRetry: groups.reload },
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
description="Every screen Signage has seen, and what it last reported."
|
actions={
|
||||||
title="Displays"
|
canManage ? (
|
||||||
>
|
<div className="signage-inline-form signage-new-group">
|
||||||
{renderLoadable(
|
<Field label="New channel group">
|
||||||
viewers.state,
|
{(control) => (
|
||||||
(value) =>
|
<TextInput
|
||||||
value.viewers.length === 0 ? (
|
{...control}
|
||||||
<EmptyState
|
onChange={(event) => setGroupName(event.target.value)}
|
||||||
description="Open a Signage display on a screen and it will register itself here."
|
placeholder="Nursery hallway"
|
||||||
title="No displays yet"
|
value={groupName}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
<Table label="Display health">
|
</Field>
|
||||||
<TableHead>
|
<Button
|
||||||
<TableRow>
|
disabled={edit.pending || groupName.trim().length === 0}
|
||||||
<TableCell header>Name</TableCell>
|
onClick={() =>
|
||||||
<TableCell header>State</TableCell>
|
edit.run(async () => {
|
||||||
<TableCell header>Channel</TableCell>
|
await api.send("POST", "channel-groups", {
|
||||||
<TableCell header>Last seen</TableCell>
|
name: groupName.trim(),
|
||||||
<TableCell header>Offline ready</TableCell>
|
});
|
||||||
<TableCell header>Most recent error</TableCell>
|
setGroupName("");
|
||||||
</TableRow>
|
})
|
||||||
</TableHead>
|
}
|
||||||
<TableBody>
|
variant="primary"
|
||||||
{value.viewers.map((viewer) => (
|
>
|
||||||
<TableRow key={viewer.id}>
|
Create group
|
||||||
<TableCell>
|
</Button>
|
||||||
{canManage ? (
|
</div>
|
||||||
<TextInput
|
) : undefined
|
||||||
aria-label={`Name for ${viewer.name}`}
|
}
|
||||||
defaultValue={viewer.name}
|
description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
|
||||||
onBlur={(event) =>
|
title="Channel groups and displays"
|
||||||
event.target.value !== viewer.name &&
|
>
|
||||||
edit.run(() =>
|
{renderLoadable(
|
||||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
groups.state,
|
||||||
name: event.target.value,
|
(value) => {
|
||||||
}),
|
const knownChannelIds = new Set(
|
||||||
)
|
value.groups.flatMap((group) =>
|
||||||
}
|
group.channels.map((channel) => channel.id),
|
||||||
/>
|
),
|
||||||
) : (
|
);
|
||||||
viewer.name
|
const unassigned = readyViewers.filter(
|
||||||
)}
|
(viewer) =>
|
||||||
</TableCell>
|
viewer.channelId === null ||
|
||||||
<TableCell>
|
!knownChannelIds.has(viewer.channelId),
|
||||||
<OnlineBadge online={viewer.online} />
|
);
|
||||||
</TableCell>
|
return (
|
||||||
<TableCell>
|
<div className="signage-groups" data-testid="signage-groups">
|
||||||
{canManage ? (
|
{value.groups.map((group) => (
|
||||||
<Select
|
<article className="signage-group" key={group.id}>
|
||||||
aria-label={`Channel for ${viewer.name}`}
|
<header className="signage-group__header">
|
||||||
onChange={(event) =>
|
<div className="signage-group__identity">
|
||||||
edit.run(() =>
|
<span className="signage-hierarchy-label">
|
||||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
Channel group
|
||||||
channelId: event.target.value,
|
</span>
|
||||||
}),
|
<h3>{group.name}</h3>
|
||||||
)
|
</div>
|
||||||
}
|
<Badge tone="info">{group.playback.transport}</Badge>
|
||||||
value={viewer.channelId ?? ""}
|
{canManage && value.groups.length > 1 ? (
|
||||||
>
|
<Button
|
||||||
{channels.map((channel) => (
|
disabled={edit.pending}
|
||||||
<option key={channel.id} value={channel.id}>
|
onClick={() => setConfirmGroupId(group.id)}
|
||||||
{`${channel.groupName} · ${channel.name}`}
|
variant="danger"
|
||||||
</option>
|
>
|
||||||
))}
|
Delete group
|
||||||
</Select>
|
</Button>
|
||||||
) : (
|
) : canManage ? (
|
||||||
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
|
<Badge tone="neutral">Required group</Badge>
|
||||||
)}
|
) : undefined}
|
||||||
</TableCell>
|
</header>
|
||||||
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
|
{confirmGroupId === group.id ? (
|
||||||
<TableCell>
|
<div className="signage-confirm" role="alert">
|
||||||
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
<p>
|
||||||
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
|
Delete {group.name}? Its playback history will be
|
||||||
</Badge>
|
removed. Displays assigned here will move to the
|
||||||
</TableCell>
|
default channel of another group.
|
||||||
<TableCell>
|
</p>
|
||||||
{viewer.lastError === null ? (
|
<Button
|
||||||
"—"
|
disabled={edit.pending}
|
||||||
) : (
|
onClick={() => setConfirmGroupId(undefined)}
|
||||||
<span title={viewer.lastError.detail ?? undefined}>
|
>
|
||||||
{viewer.lastError.code}
|
Cancel
|
||||||
</span>
|
</Button>
|
||||||
)}
|
<Button
|
||||||
</TableCell>
|
disabled={edit.pending}
|
||||||
</TableRow>
|
onClick={() =>
|
||||||
))}
|
edit.run(async () => {
|
||||||
</TableBody>
|
await api.send(
|
||||||
</Table>
|
"DELETE",
|
||||||
),
|
`channel-groups/${group.id}`,
|
||||||
{ label: "Loading displays…", onRetry: viewers.reload },
|
);
|
||||||
)}
|
setConfirmGroupId(undefined);
|
||||||
<FailureNote failure={edit.failure} />
|
})
|
||||||
</Panel>
|
}
|
||||||
</div>
|
variant="danger"
|
||||||
);
|
>
|
||||||
|
Confirm delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
Displays inherit the channel they are assigned to.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="signage-channel-list">
|
||||||
|
{group.channels.map((channel) => {
|
||||||
|
const assigned = readyViewers.filter(
|
||||||
|
(viewer) => viewer.channelId === channel.id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="signage-channel"
|
||||||
|
key={channel.id}
|
||||||
|
>
|
||||||
|
<header className="signage-channel__header">
|
||||||
|
<div>
|
||||||
|
<h5>{channel.name}</h5>
|
||||||
|
<div className="signage-channel__meta">
|
||||||
|
{channel.isDefault ? (
|
||||||
|
<Badge tone="success">
|
||||||
|
Default channel
|
||||||
|
</Badge>
|
||||||
|
) : 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}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Remove channel
|
||||||
|
</Button>
|
||||||
|
) : undefined}
|
||||||
|
</header>
|
||||||
|
<div className="signage-channel__displays">
|
||||||
|
<span className="signage-hierarchy-label">
|
||||||
|
Displays
|
||||||
|
</span>
|
||||||
|
{assigned.length === 0 ? (
|
||||||
|
<p className="signage-channel__empty">
|
||||||
|
No displays assigned.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
assigned.map((viewer) => (
|
||||||
|
<DisplayRow
|
||||||
|
canManage={canManage}
|
||||||
|
groups={value.groups}
|
||||||
|
key={viewer.id}
|
||||||
|
nowMs={nowMs}
|
||||||
|
onChannelChange={(channelId) =>
|
||||||
|
updateViewer(viewer.id, { channelId })
|
||||||
|
}
|
||||||
|
onNameChange={(name) =>
|
||||||
|
updateViewer(viewer.id, { name })
|
||||||
|
}
|
||||||
|
viewer={viewer}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<div
|
||||||
|
className="signage-channel-form"
|
||||||
|
data-testid="signage-add-channel"
|
||||||
|
>
|
||||||
|
<div className="signage-channel-form__heading">
|
||||||
|
<h5>Add an audience channel</h5>
|
||||||
|
<span>
|
||||||
|
The default channel always remains available.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
hint="Shown to administrators and when assigning displays."
|
||||||
|
label="Channel name"
|
||||||
|
>
|
||||||
|
{(control) => (
|
||||||
|
<TextInput
|
||||||
|
{...control}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(event) =>
|
||||||
|
setChannelDraft((current) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
key:
|
||||||
|
current.groupId === group.id
|
||||||
|
? current.key
|
||||||
|
: "",
|
||||||
|
name: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Online audience"
|
||||||
|
value={
|
||||||
|
channelDraft.groupId === group.id
|
||||||
|
? channelDraft.name
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
hint="Advanced: lowercase ID sent to content providers, e.g. online."
|
||||||
|
label="Audience key"
|
||||||
|
>
|
||||||
|
{(control) => (
|
||||||
|
<TextInput
|
||||||
|
{...control}
|
||||||
|
maxLength={64}
|
||||||
|
onChange={(event) =>
|
||||||
|
setChannelDraft((current) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
key: event.target.value,
|
||||||
|
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
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
edit.pending ||
|
||||||
|
channelDraft.groupId !== group.id ||
|
||||||
|
!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
|
||||||
|
channelDraft.key.trim() === "default" ||
|
||||||
|
channelDraft.name.trim() === ""
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(async () => {
|
||||||
|
await api.send(
|
||||||
|
"POST",
|
||||||
|
`channel-groups/${group.id}/channels`,
|
||||||
|
{
|
||||||
|
key: channelDraft.key.trim(),
|
||||||
|
name: channelDraft.name.trim(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setChannelDraft({
|
||||||
|
groupId: "",
|
||||||
|
key: "",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Add channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{unassigned.length === 0 ? undefined : (
|
||||||
|
<section className="signage-unassigned">
|
||||||
|
<span className="signage-hierarchy-label">
|
||||||
|
Needs assignment
|
||||||
|
</span>
|
||||||
|
{unassigned.map((viewer) => (
|
||||||
|
<DisplayRow
|
||||||
|
canManage={canManage}
|
||||||
|
groups={value.groups}
|
||||||
|
key={viewer.id}
|
||||||
|
nowMs={nowMs}
|
||||||
|
onChannelChange={(channelId) =>
|
||||||
|
updateViewer(viewer.id, { channelId })
|
||||||
|
}
|
||||||
|
onNameChange={(name) =>
|
||||||
|
updateViewer(viewer.id, { name })
|
||||||
|
}
|
||||||
|
viewer={viewer}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ label: "Loading channel groups…", onRetry: groups.reload },
|
||||||
|
)}
|
||||||
|
{renderLoadable(viewers.state, () => null, {
|
||||||
|
label: "Loading displays…",
|
||||||
|
onRetry: viewers.reload,
|
||||||
|
})}
|
||||||
|
<FailureNote failure={edit.failure} />
|
||||||
|
<FailureNote failure={viewerEdit.failure} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+331
-248
@@ -1,16 +1,29 @@
|
|||||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
|
import {
|
||||||
|
ActionBar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
EmptyState,
|
||||||
|
Field,
|
||||||
|
Panel,
|
||||||
|
Select,
|
||||||
|
} from "@ekklesia/ui";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
import { useAction, useResource, useTick } from "../hooks.js";
|
import { useAction, useResource, useTick } from "../hooks.js";
|
||||||
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 { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
import {
|
||||||
|
FailureNote,
|
||||||
|
formatDuration,
|
||||||
|
formatInstant,
|
||||||
|
renderLoadable,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live transport for one channel group.
|
* Live transport for one channel group.
|
||||||
@@ -20,255 +33,325 @@ import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./sh
|
|||||||
* 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"),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const timelines = useResource(
|
const timelines = useResource(
|
||||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const detail = useResource(
|
const detail = useResource(
|
||||||
() =>
|
() =>
|
||||||
groupId === undefined
|
groupId === undefined
|
||||||
? Promise.resolve(undefined)
|
? Promise.resolve(undefined)
|
||||||
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
||||||
[groupId],
|
[groupId],
|
||||||
);
|
);
|
||||||
const command = useAction(() => {
|
const command = useAction(() => {
|
||||||
detail.reload();
|
detail.reload();
|
||||||
groups.reload();
|
groups.reload();
|
||||||
});
|
});
|
||||||
const [activateId, setActivateId] = useState("");
|
const [activateId, setActivateId] = useState("");
|
||||||
const previewChannelId =
|
const previewChannelId =
|
||||||
groups.state.status === "ready"
|
groups.state.status === "ready"
|
||||||
? (groups.state.value.groups
|
? (groups.state.value.groups
|
||||||
.find((group) => group.id === groupId)
|
.find((group) => group.id === groupId)
|
||||||
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
||||||
useTick(1000);
|
useTick(1000);
|
||||||
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function timelineLabel(timelineId: string): string {
|
||||||
<div className="signage-columns">
|
if (timelines.state.status !== "ready") {
|
||||||
<Panel
|
return timelineId;
|
||||||
actions={renderLoadable(
|
}
|
||||||
groups.state,
|
return (
|
||||||
(value) => (
|
timelines.state.value.timelines.find(
|
||||||
<Field label="Channel group">
|
(timeline) => timeline.id === timelineId,
|
||||||
{(control) => (
|
)?.name ?? timelineId
|
||||||
<Select
|
);
|
||||||
{...control}
|
}
|
||||||
onChange={(event) => onGroupChange(event.target.value)}
|
|
||||||
value={groupId ?? ""}
|
|
||||||
>
|
|
||||||
{value.groups.map((group) => (
|
|
||||||
<option key={group.id} value={group.id}>
|
|
||||||
{group.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
),
|
|
||||||
{ label: "Loading groups…" },
|
|
||||||
)}
|
|
||||||
description="One clock for every channel in the group."
|
|
||||||
title="Playback"
|
|
||||||
>
|
|
||||||
{groupId === undefined ? (
|
|
||||||
<EmptyState title="No channel group selected" />
|
|
||||||
) : (
|
|
||||||
renderLoadable(
|
|
||||||
detail.state,
|
|
||||||
(value) =>
|
|
||||||
value === undefined ? (
|
|
||||||
<EmptyState title="No channel group selected" />
|
|
||||||
) : (
|
|
||||||
<div className="signage-playback" data-testid="signage-playback">
|
|
||||||
<div className="signage-playback__status">
|
|
||||||
<Badge
|
|
||||||
tone={
|
|
||||||
value.snapshot.transport === "playing"
|
|
||||||
? "success"
|
|
||||||
: value.snapshot.transport === "idle"
|
|
||||||
? "warning"
|
|
||||||
: "info"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{value.snapshot.transport}
|
|
||||||
</Badge>
|
|
||||||
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
|
|
||||||
{value.current?.stale === true ? (
|
|
||||||
<Badge tone="warning">Stale</Badge>
|
|
||||||
) : undefined}
|
|
||||||
{value.current?.withdrawn === true ? (
|
|
||||||
<Badge tone="warning">Withdrawn</Badge>
|
|
||||||
) : undefined}
|
|
||||||
{value.snapshot.pendingTimelineId === null ? undefined : (
|
|
||||||
<Badge tone="info">Activation queued</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{value.current === null ? (
|
return (
|
||||||
<EmptyState
|
<div className="signage-columns signage-playback-layout">
|
||||||
description="Nothing is currently resolvable. A display shows its safe slate."
|
<Panel
|
||||||
title="Nothing on screen"
|
actions={renderLoadable(
|
||||||
/>
|
groups.state,
|
||||||
) : (
|
(value) => (
|
||||||
<dl className="signage-playback__current" data-testid="signage-current">
|
<Field label="Channel group">
|
||||||
<dt>Provider</dt>
|
{(control) => (
|
||||||
<dd>{value.current.providerId}</dd>
|
<Select
|
||||||
<dt>Item</dt>
|
{...control}
|
||||||
<dd>{value.current.itemId}</dd>
|
onChange={(event) => onGroupChange(event.target.value)}
|
||||||
<dt>Renderer</dt>
|
value={groupId ?? ""}
|
||||||
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
>
|
||||||
<dt>Duration</dt>
|
{value.groups.map((group) => (
|
||||||
<dd>{formatDuration(value.current.durationMs)}</dd>
|
<option key={group.id} value={group.id}>
|
||||||
<dt>Remaining</dt>
|
{group.name}
|
||||||
<dd data-testid="signage-remaining">
|
</option>
|
||||||
{value.snapshot.remainingMs === null
|
))}
|
||||||
? "—"
|
</Select>
|
||||||
: formatDuration(value.snapshot.remainingMs)}
|
)}
|
||||||
</dd>
|
</Field>
|
||||||
<dt>Started</dt>
|
),
|
||||||
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
|
{ label: "Loading groups…" },
|
||||||
</dl>
|
)}
|
||||||
)}
|
description="One clock for every channel in the group."
|
||||||
|
title="Playback"
|
||||||
|
>
|
||||||
|
{groupId === undefined ? (
|
||||||
|
<EmptyState title="No channel group selected" />
|
||||||
|
) : (
|
||||||
|
renderLoadable(
|
||||||
|
detail.state,
|
||||||
|
(value) =>
|
||||||
|
value === undefined ? (
|
||||||
|
<EmptyState title="No channel group selected" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="signage-playback"
|
||||||
|
data-testid="signage-playback"
|
||||||
|
>
|
||||||
|
<div className="signage-playback__status">
|
||||||
|
{value.snapshot.activeTimelineId === null ? undefined : (
|
||||||
|
<Badge tone="success">
|
||||||
|
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
tone={
|
||||||
|
value.snapshot.transport === "playing"
|
||||||
|
? "success"
|
||||||
|
: value.snapshot.transport === "idle"
|
||||||
|
? "warning"
|
||||||
|
: "info"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value.snapshot.transport}
|
||||||
|
</Badge>
|
||||||
|
{value.snapshot.blackout ? (
|
||||||
|
<Badge tone="danger">Blackout</Badge>
|
||||||
|
) : undefined}
|
||||||
|
{value.current?.stale === true ? (
|
||||||
|
<Badge tone="warning">Stale</Badge>
|
||||||
|
) : undefined}
|
||||||
|
{value.current?.withdrawn === true ? (
|
||||||
|
<Badge tone="warning">Withdrawn</Badge>
|
||||||
|
) : undefined}
|
||||||
|
{value.snapshot.pendingTimelineId === null ? undefined : (
|
||||||
|
<Badge tone="info">Activation queued</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{canControl ? (
|
{value.current === null ? (
|
||||||
<div className="signage-playback__controls" data-testid="signage-controls">
|
<EmptyState
|
||||||
<Button disabled={command.pending} onClick={() => run("previous")}>
|
description="Nothing is currently resolvable. A display shows its safe slate."
|
||||||
Previous
|
title="Nothing on screen"
|
||||||
</Button>
|
/>
|
||||||
{value.snapshot.transport === "paused" ? (
|
) : (
|
||||||
<Button
|
<section className="signage-playback__now">
|
||||||
disabled={command.pending}
|
<h3>Now showing</h3>
|
||||||
onClick={() => run("resume")}
|
<dl
|
||||||
variant="primary"
|
className="signage-playback__current"
|
||||||
>
|
data-testid="signage-current"
|
||||||
Resume
|
>
|
||||||
</Button>
|
<dt>Provider</dt>
|
||||||
) : (
|
<dd>{value.current.providerId}</dd>
|
||||||
<Button disabled={command.pending} onClick={() => run("pause")}>
|
<dt>Item</dt>
|
||||||
Pause
|
<dd>{value.current.itemId}</dd>
|
||||||
</Button>
|
<dt>Renderer</dt>
|
||||||
)}
|
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
||||||
<Button disabled={command.pending} onClick={() => run("next")}>
|
<dt>Duration</dt>
|
||||||
Next
|
<dd>{formatDuration(value.current.durationMs)}</dd>
|
||||||
</Button>
|
<dt>Remaining</dt>
|
||||||
<Button
|
<dd data-testid="signage-remaining">
|
||||||
disabled={command.pending}
|
{value.snapshot.remainingMs === null
|
||||||
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
|
? "—"
|
||||||
variant={value.snapshot.blackout ? "primary" : "secondary"}
|
: formatDuration(value.snapshot.remainingMs)}
|
||||||
>
|
</dd>
|
||||||
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
<dt>Started</dt>
|
||||||
</Button>
|
<dd>
|
||||||
{value.snapshot.staticAssetId === null ? undefined : (
|
{formatInstant(
|
||||||
<Button
|
value.current.startedAtMs,
|
||||||
disabled={command.pending}
|
settings.timezone,
|
||||||
onClick={() => run("static", { assetId: null })}
|
)}
|
||||||
>
|
</dd>
|
||||||
Release hold
|
</dl>
|
||||||
</Button>
|
</section>
|
||||||
)}
|
)}
|
||||||
<Button disabled={command.pending} onClick={() => run("reload")}>
|
|
||||||
Reload content
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{canControl ? (
|
{canControl ? (
|
||||||
<div className="signage-playback__activate">
|
<ActionBar
|
||||||
{renderLoadable(
|
className="signage-playback__controls"
|
||||||
timelines.state,
|
data-testid="signage-controls"
|
||||||
(loaded) => (
|
label="Playback controls"
|
||||||
<>
|
>
|
||||||
<Field label="Activate timeline">
|
<Button
|
||||||
{(control) => (
|
disabled={command.pending}
|
||||||
<Select
|
onClick={() => run("previous")}
|
||||||
{...control}
|
>
|
||||||
onChange={(event) => setActivateId(event.target.value)}
|
Previous
|
||||||
value={activateId}
|
</Button>
|
||||||
>
|
{value.snapshot.transport === "paused" ? (
|
||||||
<option value="">Choose a timeline…</option>
|
<Button
|
||||||
{loaded.timelines.map((timeline) => (
|
disabled={command.pending}
|
||||||
<option key={timeline.id} value={timeline.id}>
|
onClick={() => run("resume")}
|
||||||
{timeline.name}
|
variant="primary"
|
||||||
</option>
|
>
|
||||||
))}
|
Resume
|
||||||
</Select>
|
</Button>
|
||||||
)}
|
) : (
|
||||||
</Field>
|
<Button
|
||||||
<Button
|
disabled={command.pending}
|
||||||
disabled={command.pending || activateId === ""}
|
onClick={() => run("pause")}
|
||||||
onClick={() =>
|
>
|
||||||
run("activate", { timelineId: activateId, when: "now" })
|
Pause
|
||||||
}
|
</Button>
|
||||||
variant="primary"
|
)}
|
||||||
>
|
<Button
|
||||||
Now
|
disabled={command.pending}
|
||||||
</Button>
|
onClick={() => run("next")}
|
||||||
<Button
|
>
|
||||||
disabled={command.pending || activateId === ""}
|
Next
|
||||||
onClick={() =>
|
</Button>
|
||||||
run("activate", { timelineId: activateId, when: "next-boundary" })
|
<Button
|
||||||
}
|
disabled={command.pending}
|
||||||
>
|
onClick={() =>
|
||||||
At next slot
|
run("blackout", { on: !value.snapshot.blackout })
|
||||||
</Button>
|
}
|
||||||
</>
|
variant={
|
||||||
),
|
value.snapshot.blackout ? "primary" : "secondary"
|
||||||
{ label: "Loading timelines…" },
|
}
|
||||||
)}
|
>
|
||||||
</div>
|
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
||||||
) : undefined}
|
</Button>
|
||||||
|
{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}
|
||||||
|
|
||||||
{value.history.length === 0 ? undefined : (
|
{canControl ? (
|
||||||
<div className="signage-playback__history">
|
<div className="signage-playback__activate">
|
||||||
<h4 className="signage-editor__subtitle">Recently played</h4>
|
{renderLoadable(
|
||||||
<ul className="ek-list">
|
timelines.state,
|
||||||
{value.history.slice(0, 8).map((entry) => (
|
(loaded) => (
|
||||||
<li className="ek-list__item signage-row" key={entry.id}>
|
<>
|
||||||
<span className="signage-row__name">{entry.itemId}</span>
|
<Field label="Activate timeline">
|
||||||
<span className="signage-playback__history-meta">
|
{(control) => (
|
||||||
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
<Select
|
||||||
</span>
|
{...control}
|
||||||
</li>
|
onChange={(event) =>
|
||||||
))}
|
setActivateId(event.target.value)
|
||||||
</ul>
|
}
|
||||||
</div>
|
value={activateId}
|
||||||
)}
|
>
|
||||||
</div>
|
<option value="">Choose a timeline…</option>
|
||||||
),
|
{loaded.timelines.map((timeline) => (
|
||||||
{ label: "Loading playback…", onRetry: detail.reload },
|
<option
|
||||||
)
|
key={timeline.id}
|
||||||
)}
|
value={timeline.id}
|
||||||
<FailureNote failure={command.failure} />
|
>
|
||||||
</Panel>
|
{timeline.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
disabled={command.pending || activateId === ""}
|
||||||
|
onClick={() =>
|
||||||
|
run("activate", {
|
||||||
|
timelineId: activateId,
|
||||||
|
when: "now",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={command.pending || activateId === ""}
|
||||||
|
onClick={() =>
|
||||||
|
run("activate", {
|
||||||
|
timelineId: activateId,
|
||||||
|
when: "next-boundary",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
At next slot
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
{ label: "Loading timelines…" },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
|
{value.history.length === 0 ? undefined : (
|
||||||
</div>
|
<div className="signage-playback__history">
|
||||||
);
|
<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>
|
||||||
|
<span className="signage-playback__history-meta">
|
||||||
|
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ label: "Loading playback…", onRetry: detail.reload },
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<FailureNote failure={command.failure} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,86 +31,101 @@ 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"),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const published = new Set(
|
const published = new Set(
|
||||||
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
|
context.contributions
|
||||||
);
|
.list(SLIDE_RENDERER_CAPABILITY)
|
||||||
|
.map((contribution) => contribution.id),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
|
actions={
|
||||||
title="Content providers"
|
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
|
||||||
>
|
}
|
||||||
{renderLoadable(
|
description="Running content sources available to timelines."
|
||||||
providers.state,
|
title="Content providers"
|
||||||
(value) =>
|
>
|
||||||
value.providers.length === 0 ? (
|
{renderLoadable(
|
||||||
<EmptyState
|
providers.state,
|
||||||
description="Signage works on its own with native images. Install a content module to add more."
|
(value) =>
|
||||||
title="No content providers registered"
|
value.providers.length === 0 ? (
|
||||||
/>
|
<EmptyState
|
||||||
) : (
|
description="Signage works on its own with native images. Install a content module to add more."
|
||||||
<Table label="Provider health">
|
title="No content providers registered"
|
||||||
<TableHead>
|
/>
|
||||||
<TableRow>
|
) : (
|
||||||
<TableCell header>Provider</TableCell>
|
<Table label="Provider health">
|
||||||
<TableCell header>Contract</TableCell>
|
<TableHead>
|
||||||
<TableCell header>Renderer</TableCell>
|
<TableRow>
|
||||||
<TableCell header>Health</TableCell>
|
<TableCell header>Provider</TableCell>
|
||||||
<TableCell header>Last success</TableCell>
|
<TableCell header>Contract</TableCell>
|
||||||
</TableRow>
|
<TableCell header>Renderer</TableCell>
|
||||||
</TableHead>
|
<TableCell header>Health</TableCell>
|
||||||
<TableBody>
|
<TableCell header>Last success</TableCell>
|
||||||
{value.providers.map((provider) => {
|
</TableRow>
|
||||||
const rendererPublished = published.has(provider.renderer.contributionId);
|
</TableHead>
|
||||||
const isNative = provider.renderer.contributionId === "signage.native-image";
|
<TableBody>
|
||||||
return (
|
{value.providers.map((provider) => {
|
||||||
<TableRow key={provider.providerId}>
|
const rendererPublished = published.has(
|
||||||
<TableCell>
|
provider.renderer.contributionId,
|
||||||
<span className="signage-provider__id">{provider.providerId}</span>
|
);
|
||||||
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
const isNative =
|
||||||
</TableCell>
|
provider.renderer.contributionId === "signage.native-image";
|
||||||
<TableCell>{provider.contractVersion}</TableCell>
|
return (
|
||||||
<TableCell>
|
<TableRow key={provider.providerId}>
|
||||||
{isNative ? (
|
<TableCell>
|
||||||
<Badge tone="success">Signage renders this</Badge>
|
<span className="signage-provider__id">
|
||||||
) : rendererPublished ? (
|
{provider.providerId}
|
||||||
<Badge tone="success">{provider.renderer.contributionId}</Badge>
|
</span>
|
||||||
) : (
|
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
||||||
<Badge tone="warning">Renderer not published</Badge>
|
</TableCell>
|
||||||
)}
|
<TableCell>{provider.contractVersion}</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
{isNative ? (
|
||||||
{provider.consecutiveFailures === 0 ? (
|
<Badge tone="info">Built in</Badge>
|
||||||
<Badge tone="success">Healthy</Badge>
|
) : rendererPublished ? (
|
||||||
) : (
|
<Badge tone="success">
|
||||||
<Badge tone="danger">
|
{provider.renderer.contributionId}
|
||||||
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
</Badge>
|
||||||
</Badge>
|
) : (
|
||||||
)}
|
<Badge tone="warning">Renderer not published</Badge>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
{formatInstant(provider.lastSuccessAt, settings.timezone)}
|
<TableCell>
|
||||||
</TableCell>
|
{provider.consecutiveFailures === 0 ? (
|
||||||
</TableRow>
|
<Badge tone="success">Healthy</Badge>
|
||||||
);
|
) : (
|
||||||
})}
|
<Badge tone="danger">
|
||||||
</TableBody>
|
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
||||||
</Table>
|
</Badge>
|
||||||
),
|
)}
|
||||||
{ label: "Loading providers…", onRetry: providers.reload },
|
</TableCell>
|
||||||
)}
|
<TableCell>
|
||||||
</Panel>
|
{formatInstant(
|
||||||
);
|
provider.lastSuccessAt,
|
||||||
|
settings.timezone,
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
),
|
||||||
|
{ label: "Loading providers…", onRetry: providers.reload },
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+527
-438
@@ -1,31 +1,36 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
Field,
|
Field,
|
||||||
Panel,
|
Panel,
|
||||||
Select,
|
Select,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@ekklesia/ui";
|
} from "@ekklesia/ui";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import type { SignageApi } from "../api.js";
|
import type { SignageApi } from "../api.js";
|
||||||
import { useAction, useResource } from "../hooks.js";
|
import { useAction, useResource } from "../hooks.js";
|
||||||
import type {
|
import type {
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
ProviderHealth,
|
ProviderHealth,
|
||||||
ProviderItemDescriptor,
|
ProviderItemDescriptor,
|
||||||
ProviderSourceDescriptor,
|
ProviderSourceDescriptor,
|
||||||
SettingsResponse,
|
SettingsResponse,
|
||||||
TimelineDetail,
|
TimelineDetail,
|
||||||
TimelineSummary,
|
TimelineSummary,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
import {
|
||||||
|
FailureNote,
|
||||||
|
formatDuration,
|
||||||
|
formatInstant,
|
||||||
|
renderLoadable,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
const NATIVE_PROVIDER_ID = "signage.native-images";
|
const NATIVE_PROVIDER_ID = "signage.native-images";
|
||||||
|
|
||||||
@@ -38,439 +43,523 @@ const NATIVE_PROVIDER_ID = "signage.native-images";
|
|||||||
* that visible by naming the source on every row rather than hiding it.
|
* that visible by naming the source on every row rather than hiding it.
|
||||||
*/
|
*/
|
||||||
export function TimelinesPanel({
|
export function TimelinesPanel({
|
||||||
api,
|
api,
|
||||||
settings,
|
settings,
|
||||||
canManage,
|
canManage,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
activeTimelineId,
|
||||||
|
activeGroupName,
|
||||||
}: {
|
}: {
|
||||||
readonly api: SignageApi;
|
readonly api: SignageApi;
|
||||||
readonly settings: SettingsResponse;
|
readonly settings: SettingsResponse;
|
||||||
readonly canManage: boolean;
|
readonly canManage: boolean;
|
||||||
readonly onActivate: ((timelineId: string) => void) | undefined;
|
readonly onActivate: ((timelineId: string) => Promise<void>) | undefined;
|
||||||
|
readonly activeTimelineId?: string | null;
|
||||||
|
readonly activeGroupName?: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const timelines = useResource(
|
const timelines = useResource(
|
||||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const providers = useResource(
|
const providers = useResource(
|
||||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const create = useAction(() => {
|
const create = useAction(() => {
|
||||||
setNewName("");
|
setNewName("");
|
||||||
timelines.reload();
|
timelines.reload();
|
||||||
});
|
});
|
||||||
|
const activation = useAction();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signage-columns">
|
<div className="signage-columns">
|
||||||
<Panel
|
<Panel
|
||||||
actions={
|
actions={
|
||||||
canManage ? (
|
canManage ? (
|
||||||
<div className="signage-inline-form">
|
<div className="signage-inline-form">
|
||||||
<Field label="New timeline">
|
<Field label="New timeline">
|
||||||
{(control) => (
|
{(control) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...control}
|
{...control}
|
||||||
onChange={(event) => setNewName(event.target.value)}
|
onChange={(event) => setNewName(event.target.value)}
|
||||||
placeholder="Sunday morning"
|
placeholder="Sunday morning"
|
||||||
value={newName}
|
value={newName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Button
|
<Button
|
||||||
disabled={create.pending || newName.trim().length === 0}
|
disabled={create.pending || newName.trim().length === 0}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
|
create.run(() =>
|
||||||
}
|
api.send("POST", "timelines", { name: newName.trim() }),
|
||||||
variant="primary"
|
)
|
||||||
>
|
}
|
||||||
Create
|
variant="primary"
|
||||||
</Button>
|
>
|
||||||
</div>
|
Create
|
||||||
) : undefined
|
</Button>
|
||||||
}
|
</div>
|
||||||
description="Saved patterns an operator builds on demand. Signage ships none."
|
) : undefined
|
||||||
title="Timelines"
|
}
|
||||||
>
|
description="Saved patterns an operator builds on demand. Signage ships none."
|
||||||
{renderLoadable(
|
title="Timelines"
|
||||||
timelines.state,
|
>
|
||||||
(value) =>
|
{renderLoadable(
|
||||||
value.timelines.length === 0 ? (
|
timelines.state,
|
||||||
<EmptyState
|
(value) =>
|
||||||
description="Create one and add slots to it. There are no presets, on purpose."
|
value.timelines.length === 0 ? (
|
||||||
title="No timelines yet"
|
<EmptyState
|
||||||
/>
|
description="Create one and add slots to it. There are no presets, on purpose."
|
||||||
) : (
|
title="No timelines yet"
|
||||||
<Table label="Saved timelines">
|
/>
|
||||||
<TableHead>
|
) : (
|
||||||
<TableRow>
|
<Table label="Saved timelines">
|
||||||
<TableCell header>Name</TableCell>
|
<TableHead>
|
||||||
<TableCell header>Eligible</TableCell>
|
<TableRow>
|
||||||
<TableCell header>Revision</TableCell>
|
<TableCell header>Name</TableCell>
|
||||||
<TableCell header>Actions</TableCell>
|
<TableCell header>Eligible</TableCell>
|
||||||
</TableRow>
|
<TableCell header>Revision</TableCell>
|
||||||
</TableHead>
|
<TableCell header>Actions</TableCell>
|
||||||
<TableBody>
|
</TableRow>
|
||||||
{value.timelines.map((timeline) => (
|
</TableHead>
|
||||||
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
|
<TableBody>
|
||||||
<TableCell>
|
{value.timelines.map((timeline) => (
|
||||||
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
|
<TableRow
|
||||||
</TableCell>
|
key={timeline.id}
|
||||||
<TableCell>
|
selected={timeline.id === selectedId}
|
||||||
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
|
>
|
||||||
? "Always"
|
<TableCell>
|
||||||
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
|
<div className="signage-timeline-name">
|
||||||
</TableCell>
|
<Button
|
||||||
<TableCell>{timeline.revision}</TableCell>
|
className="signage-timeline-select"
|
||||||
<TableCell>
|
onClick={() => setSelectedId(timeline.id)}
|
||||||
{onActivate === undefined ? undefined : (
|
variant="quiet"
|
||||||
<Button onClick={() => onActivate(timeline.id)} variant="primary">
|
>
|
||||||
Activate
|
{timeline.name}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{timeline.id === activeTimelineId ? (
|
||||||
</TableCell>
|
<Badge tone="success">
|
||||||
</TableRow>
|
{activeGroupName
|
||||||
))}
|
? `Active in ${activeGroupName}`
|
||||||
</TableBody>
|
: "Active"}
|
||||||
</Table>
|
</Badge>
|
||||||
),
|
) : undefined}
|
||||||
{ label: "Loading timelines…", onRetry: timelines.reload },
|
</div>
|
||||||
)}
|
</TableCell>
|
||||||
<FailureNote failure={create.failure} />
|
<TableCell>
|
||||||
</Panel>
|
{timeline.eligibleFrom === null &&
|
||||||
|
timeline.eligibleUntil === null
|
||||||
|
? "Always"
|
||||||
|
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{timeline.revision}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{onActivate === undefined ? undefined : (
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
activation.pending ||
|
||||||
|
timeline.id === activeTimelineId
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
activation.run(() => onActivate(timeline.id))
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
{timeline.id === activeTimelineId
|
||||||
|
? "Active"
|
||||||
|
: "Activate"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
),
|
||||||
|
{ label: "Loading timelines…", onRetry: timelines.reload },
|
||||||
|
)}
|
||||||
|
<FailureNote failure={create.failure} />
|
||||||
|
<FailureNote failure={activation.failure} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{selectedId === undefined ? undefined : (
|
{selectedId === undefined ? undefined : (
|
||||||
<TimelineEditor
|
<TimelineEditor
|
||||||
api={api}
|
api={api}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
key={selectedId}
|
key={selectedId}
|
||||||
onChanged={timelines.reload}
|
onChanged={timelines.reload}
|
||||||
onClose={() => setSelectedId(undefined)}
|
onClose={() => setSelectedId(undefined)}
|
||||||
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
|
providers={
|
||||||
settings={settings}
|
providers.state.status === "ready"
|
||||||
timelineId={selectedId}
|
? providers.state.value.providers
|
||||||
/>
|
: []
|
||||||
)}
|
}
|
||||||
</div>
|
settings={settings}
|
||||||
);
|
timelineId={selectedId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineEditor({
|
function TimelineEditor({
|
||||||
api,
|
api,
|
||||||
canManage,
|
canManage,
|
||||||
onChanged,
|
onChanged,
|
||||||
onClose,
|
onClose,
|
||||||
providers,
|
providers,
|
||||||
settings,
|
settings,
|
||||||
timelineId,
|
timelineId,
|
||||||
}: {
|
}: {
|
||||||
readonly api: SignageApi;
|
readonly api: SignageApi;
|
||||||
readonly canManage: boolean;
|
readonly canManage: boolean;
|
||||||
readonly onChanged: () => void;
|
readonly onChanged: () => void;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
readonly providers: readonly ProviderHealth[];
|
readonly providers: readonly ProviderHealth[];
|
||||||
readonly settings: SettingsResponse;
|
readonly settings: SettingsResponse;
|
||||||
readonly timelineId: string;
|
readonly timelineId: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const detail = useResource(
|
const detail = useResource(
|
||||||
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
|
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
|
||||||
[timelineId],
|
[timelineId],
|
||||||
);
|
);
|
||||||
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
|
const images = useResource(
|
||||||
const edit = useAction(() => {
|
() => api.get<{ images: readonly ImageRecord[] }>("images"),
|
||||||
detail.reload();
|
[],
|
||||||
onChanged();
|
);
|
||||||
});
|
const edit = useAction(() => {
|
||||||
const [providerId, setProviderId] = useState("");
|
detail.reload();
|
||||||
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
|
onChanged();
|
||||||
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
|
});
|
||||||
|
const [providerId, setProviderId] = useState("");
|
||||||
|
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
|
||||||
|
|
||||||
function loadProvider(nextProviderId: string): void {
|
function loadProvider(nextProviderId: string): void {
|
||||||
setProviderId(nextProviderId);
|
setProviderId(nextProviderId);
|
||||||
setSources([]);
|
setSources([]);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
if (nextProviderId === "") {
|
if (nextProviderId === "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void api
|
void api
|
||||||
.get<{ sources: readonly ProviderSourceDescriptor[] }>(
|
.get<{ sources: readonly ProviderSourceDescriptor[] }>(
|
||||||
`providers/${encodeURIComponent(nextProviderId)}/sources`,
|
`providers/${encodeURIComponent(nextProviderId)}/sources`,
|
||||||
)
|
)
|
||||||
.then((response) => setSources(response.sources))
|
.then((response) => setSources(response.sources))
|
||||||
.catch(() => setSources([]));
|
.catch(() => setSources([]));
|
||||||
void api
|
void api
|
||||||
.get<{ items: readonly ProviderItemDescriptor[] }>(
|
.get<{ items: readonly ProviderItemDescriptor[] }>(
|
||||||
`providers/${encodeURIComponent(nextProviderId)}/items`,
|
`providers/${encodeURIComponent(nextProviderId)}/items`,
|
||||||
)
|
)
|
||||||
.then((response) => setItems(response.items))
|
.then((response) => setItems(response.items))
|
||||||
.catch(() => setItems([]));
|
.catch(() => setItems([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
actions={<Button onClick={onClose}>Close</Button>}
|
actions={<Button onClick={onClose}>Close</Button>}
|
||||||
description="Slots run in order and repeat. Repeating one source is one rotation, not several."
|
description="Slots run in order and repeat. Repeating one source is one rotation, not several."
|
||||||
title="Timeline pattern"
|
title="Timeline pattern"
|
||||||
>
|
>
|
||||||
{renderLoadable(
|
{renderLoadable(
|
||||||
detail.state,
|
detail.state,
|
||||||
(value) => (
|
(value) => (
|
||||||
<div className="signage-editor" data-testid="signage-timeline-editor">
|
<div className="signage-editor" data-testid="signage-timeline-editor">
|
||||||
<h3 className="signage-editor__title">{value.timeline.name}</h3>
|
<h3 className="signage-editor__title">{value.timeline.name}</h3>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div className="signage-editor__add" data-testid="signage-add-source">
|
<div
|
||||||
<Field
|
className="signage-editor__add"
|
||||||
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
|
data-testid="signage-add-source"
|
||||||
label="Content provider"
|
>
|
||||||
>
|
<Field
|
||||||
{(control) => (
|
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
|
||||||
<Select
|
label="Content provider"
|
||||||
{...control}
|
>
|
||||||
onChange={(event) => loadProvider(event.target.value)}
|
{(control) => (
|
||||||
value={providerId}
|
<Select
|
||||||
>
|
{...control}
|
||||||
<option value="">Choose a provider…</option>
|
onChange={(event) => loadProvider(event.target.value)}
|
||||||
{providers.map((provider) => (
|
value={providerId}
|
||||||
<option key={provider.providerId} value={provider.providerId}>
|
>
|
||||||
{provider.providerId}
|
<option value="">Choose a provider…</option>
|
||||||
</option>
|
{providers.map((provider) => (
|
||||||
))}
|
<option
|
||||||
</Select>
|
key={provider.providerId}
|
||||||
)}
|
value={provider.providerId}
|
||||||
</Field>
|
>
|
||||||
{sources.map((source) => (
|
{provider.providerId}
|
||||||
<Button
|
</option>
|
||||||
disabled={edit.pending}
|
))}
|
||||||
key={source.id}
|
</Select>
|
||||||
onClick={() =>
|
)}
|
||||||
edit.run(() =>
|
</Field>
|
||||||
api.send("POST", `timelines/${timelineId}/sources`, {
|
{sources.map((source) => (
|
||||||
label: source.label,
|
<Button
|
||||||
providerId,
|
disabled={edit.pending}
|
||||||
sourceId: source.id,
|
key={source.id}
|
||||||
}),
|
onClick={() =>
|
||||||
)
|
edit.run(() =>
|
||||||
}
|
api.send("POST", `timelines/${timelineId}/sources`, {
|
||||||
>
|
label: source.label,
|
||||||
{`Add source: ${source.label}`}
|
providerId,
|
||||||
</Button>
|
sourceId: source.id,
|
||||||
))}
|
}),
|
||||||
{items.slice(0, 20).map((item) => (
|
)
|
||||||
<Button
|
}
|
||||||
disabled={edit.pending}
|
>
|
||||||
key={item.id}
|
{`Add source: ${source.label}`}
|
||||||
onClick={() =>
|
</Button>
|
||||||
edit.run(() =>
|
))}
|
||||||
api.send("POST", `timelines/${timelineId}/slots`, {
|
{items.slice(0, 20).map((item) => (
|
||||||
itemId: item.id,
|
<Button
|
||||||
providerId,
|
disabled={edit.pending}
|
||||||
}),
|
key={item.id}
|
||||||
)
|
onClick={() =>
|
||||||
}
|
edit.run(() =>
|
||||||
>
|
api.send("POST", `timelines/${timelineId}/slots`, {
|
||||||
{`Pin item: ${item.label}`}
|
itemId: item.id,
|
||||||
</Button>
|
providerId,
|
||||||
))}
|
}),
|
||||||
</div>
|
)
|
||||||
) : undefined}
|
}
|
||||||
|
>
|
||||||
|
{`Pin item: ${item.label}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
{value.sources.length === 0 ? undefined : (
|
{value.sources.length === 0 ? undefined : (
|
||||||
<div className="signage-editor__sources">
|
<div className="signage-editor__sources">
|
||||||
<h4 className="signage-editor__subtitle">Configured sources</h4>
|
<h4 className="signage-editor__subtitle">Configured sources</h4>
|
||||||
{value.sources.map((source) => (
|
{value.sources.map((source) => (
|
||||||
<div className="signage-editor__source" key={source.id}>
|
<div className="signage-editor__source" key={source.id}>
|
||||||
<span>{source.label}</span>
|
<span>{source.label}</span>
|
||||||
<Badge tone="info">{source.providerId}</Badge>
|
<Badge tone="info">{source.providerId}</Badge>
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(() =>
|
edit.run(() =>
|
||||||
api.send("POST", `timelines/${timelineId}/slots`, {
|
api.send(
|
||||||
sourceId: source.id,
|
"POST",
|
||||||
}),
|
`timelines/${timelineId}/slots`,
|
||||||
)
|
{
|
||||||
}
|
sourceId: source.id,
|
||||||
>
|
},
|
||||||
Add slot
|
),
|
||||||
</Button>
|
)
|
||||||
<Button
|
}
|
||||||
disabled={edit.pending}
|
>
|
||||||
onClick={() =>
|
Add slot
|
||||||
edit.run(() =>
|
</Button>
|
||||||
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
|
<Button
|
||||||
)
|
disabled={edit.pending}
|
||||||
}
|
onClick={() =>
|
||||||
variant="danger"
|
edit.run(() =>
|
||||||
>
|
api.send(
|
||||||
Remove
|
"DELETE",
|
||||||
</Button>
|
`timelines/${timelineId}/sources/${source.id}`,
|
||||||
</>
|
),
|
||||||
) : undefined}
|
)
|
||||||
</div>
|
}
|
||||||
))}
|
variant="danger"
|
||||||
</div>
|
>
|
||||||
)}
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{value.slots.length === 0 ? (
|
{value.slots.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="Add a provider source or pin an image, then repeat it as often as the loop needs."
|
description="Add a provider source or pin an image, then repeat it as often as the loop needs."
|
||||||
title="This timeline has no slots"
|
title="This timeline has no slots"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table label="Timeline slots">
|
<Table label="Timeline slots">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell header>#</TableCell>
|
<TableCell header>#</TableCell>
|
||||||
<TableCell header>Content</TableCell>
|
<TableCell header>Content</TableCell>
|
||||||
<TableCell header>Duration</TableCell>
|
<TableCell header>Duration</TableCell>
|
||||||
<TableCell header>Enabled</TableCell>
|
<TableCell header>Enabled</TableCell>
|
||||||
<TableCell header>Order</TableCell>
|
<TableCell header>Order</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{value.slots.map((slot, index) => {
|
{value.slots.map((slot, index) => {
|
||||||
const source = value.sources.find(
|
const source = value.sources.find(
|
||||||
(candidate) => candidate.id === slot.sourceId,
|
(candidate) => candidate.id === slot.sourceId,
|
||||||
);
|
);
|
||||||
const image =
|
const image =
|
||||||
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready"
|
slot.providerId === NATIVE_PROVIDER_ID &&
|
||||||
? images.state.value.images.find(
|
images.state.status === "ready"
|
||||||
(candidate) => candidate.id === slot.itemId,
|
? images.state.value.images.find(
|
||||||
)
|
(candidate) => candidate.id === slot.itemId,
|
||||||
: undefined;
|
)
|
||||||
return (
|
: undefined;
|
||||||
<TableRow key={slot.id}>
|
return (
|
||||||
<TableCell>{slot.position + 1}</TableCell>
|
<TableRow key={slot.id}>
|
||||||
<TableCell>
|
<TableCell>{slot.position + 1}</TableCell>
|
||||||
{slot.kind === "provider-source"
|
<TableCell>
|
||||||
? (source?.label ?? "Removed source")
|
{slot.kind === "provider-source"
|
||||||
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
|
? (source?.label ?? "Removed source")
|
||||||
</TableCell>
|
: (image?.displayName ??
|
||||||
<TableCell>
|
`${slot.providerId} · ${slot.itemId}`)}
|
||||||
{slot.durationOverrideMs === null
|
</TableCell>
|
||||||
? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)`
|
<TableCell>
|
||||||
: formatDuration(slot.durationOverrideMs)}
|
{slot.durationOverrideMs === null
|
||||||
</TableCell>
|
? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)`
|
||||||
<TableCell>
|
: formatDuration(slot.durationOverrideMs)}
|
||||||
<Checkbox
|
</TableCell>
|
||||||
checked={slot.enabled}
|
<TableCell>
|
||||||
disabled={!canManage || edit.pending}
|
<Checkbox
|
||||||
label={slot.enabled ? "On" : "Off"}
|
checked={slot.enabled}
|
||||||
onChange={(event) =>
|
disabled={!canManage || edit.pending}
|
||||||
edit.run(() =>
|
label={slot.enabled ? "On" : "Off"}
|
||||||
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
|
onChange={(event) =>
|
||||||
enabled: event.target.checked,
|
edit.run(() =>
|
||||||
}),
|
api.send(
|
||||||
)
|
"PATCH",
|
||||||
}
|
`timelines/${timelineId}/slots/${slot.id}`,
|
||||||
/>
|
{
|
||||||
</TableCell>
|
enabled: event.target.checked,
|
||||||
<TableCell>
|
},
|
||||||
{canManage ? (
|
),
|
||||||
<div className="signage-editor__order">
|
)
|
||||||
<Button
|
}
|
||||||
disabled={index === 0 || edit.pending}
|
/>
|
||||||
onClick={() =>
|
</TableCell>
|
||||||
edit.run(() =>
|
<TableCell>
|
||||||
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
{canManage ? (
|
||||||
slotIds: swap(
|
<div className="signage-editor__order">
|
||||||
value.slots.map((candidate) => candidate.id),
|
<Button
|
||||||
index,
|
disabled={index === 0 || edit.pending}
|
||||||
index - 1,
|
onClick={() =>
|
||||||
),
|
edit.run(() =>
|
||||||
}),
|
api.send(
|
||||||
)
|
"PUT",
|
||||||
}
|
`timelines/${timelineId}/slot-order`,
|
||||||
>
|
{
|
||||||
Up
|
slotIds: swap(
|
||||||
</Button>
|
value.slots.map(
|
||||||
<Button
|
(candidate) => candidate.id,
|
||||||
disabled={index === value.slots.length - 1 || edit.pending}
|
),
|
||||||
onClick={() =>
|
index,
|
||||||
edit.run(() =>
|
index - 1,
|
||||||
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
),
|
||||||
slotIds: swap(
|
},
|
||||||
value.slots.map((candidate) => candidate.id),
|
),
|
||||||
index,
|
)
|
||||||
index + 1,
|
}
|
||||||
),
|
>
|
||||||
}),
|
Up
|
||||||
)
|
</Button>
|
||||||
}
|
<Button
|
||||||
>
|
disabled={
|
||||||
Down
|
index === value.slots.length - 1 ||
|
||||||
</Button>
|
edit.pending
|
||||||
<Button
|
}
|
||||||
disabled={edit.pending}
|
onClick={() =>
|
||||||
onClick={() =>
|
edit.run(() =>
|
||||||
edit.run(() =>
|
api.send(
|
||||||
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
|
"PUT",
|
||||||
)
|
`timelines/${timelineId}/slot-order`,
|
||||||
}
|
{
|
||||||
variant="danger"
|
slotIds: swap(
|
||||||
>
|
value.slots.map(
|
||||||
Remove
|
(candidate) => candidate.id,
|
||||||
</Button>
|
),
|
||||||
</div>
|
index,
|
||||||
) : undefined}
|
index + 1,
|
||||||
</TableCell>
|
),
|
||||||
</TableRow>
|
},
|
||||||
);
|
),
|
||||||
})}
|
)
|
||||||
</TableBody>
|
}
|
||||||
</Table>
|
>
|
||||||
)}
|
Down
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send(
|
||||||
|
"DELETE",
|
||||||
|
`timelines/${timelineId}/slots/${slot.id}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<div className="signage-editor__footer">
|
<div className="signage-editor__footer">
|
||||||
<Button
|
<Button
|
||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(() =>
|
edit.run(() =>
|
||||||
api.send("POST", `timelines/${timelineId}/duplicate`, {
|
api.send("POST", `timelines/${timelineId}/duplicate`, {
|
||||||
name: `${value.timeline.name} (copy)`,
|
name: `${value.timeline.name} (copy)`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={edit.pending}
|
disabled={edit.pending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
edit.run(async () => {
|
edit.run(async () => {
|
||||||
await api.send("DELETE", `timelines/${timelineId}`);
|
await api.send("DELETE", `timelines/${timelineId}`);
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
Delete timeline
|
Delete timeline
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
{ label: "Loading timeline…", onRetry: detail.reload },
|
{ label: "Loading timeline…", onRetry: detail.reload },
|
||||||
)}
|
)}
|
||||||
<FailureNote failure={edit.failure} />
|
<FailureNote failure={edit.failure} />
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function swap(ids: readonly string[], from: number, to: number): string[] {
|
function swap(ids: readonly string[], from: number, to: number): string[] {
|
||||||
const next = [...ids];
|
const next = [...ids];
|
||||||
const moved = next[from];
|
const moved = next[from];
|
||||||
const target = next[to];
|
const target = next[to];
|
||||||
if (moved === undefined || target === undefined) {
|
if (moved === undefined || target === undefined) {
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
next[from] = target;
|
next[from] = target;
|
||||||
next[to] = moved;
|
next[to] = moved;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|||||||
+566
-240
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,108 +15,132 @@ 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(code: string, message: string, status = 400): Response {
|
export function errorResponse(
|
||||||
return jsonResponse({ code, message }, status);
|
code: string,
|
||||||
|
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 principal?: ModuleUiHostContext["principal"];
|
readonly displayUrl?: string;
|
||||||
readonly mount?: (
|
readonly principal?: ModuleUiHostContext["principal"];
|
||||||
options: MountModuleContributionOptions,
|
readonly mount?: (
|
||||||
) => Promise<MountedModuleContribution> | MountedModuleContribution;
|
options: MountModuleContributionOptions,
|
||||||
|
) => 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("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
|
return errorResponse(
|
||||||
},
|
"SIGNAGE_NOT_FOUND",
|
||||||
};
|
"The requested resource was not found.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const contributions: ModuleUiContributions = {
|
const contributions: ModuleUiContributions = {
|
||||||
list: (capabilityId) =>
|
list: (capabilityId) =>
|
||||||
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
|
capabilityId === "signage.slide-renderer"
|
||||||
async mount(mountOptions) {
|
? (options.contributions ?? [])
|
||||||
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input });
|
: [],
|
||||||
if (options.mount) {
|
async mount(mountOptions) {
|
||||||
return options.mount(mountOptions);
|
mounted.push({
|
||||||
}
|
contributionId: mountOptions.contributionId,
|
||||||
return {
|
input: mountOptions.input,
|
||||||
contributionId: mountOptions.contributionId,
|
});
|
||||||
mounted: true,
|
if (options.mount) {
|
||||||
unmount: () => Promise.resolve(),
|
return options.mount(mountOptions);
|
||||||
update: () => Promise.resolve(),
|
}
|
||||||
version: "1.0.0",
|
return {
|
||||||
};
|
contributionId: mountOptions.contributionId,
|
||||||
},
|
mounted: true,
|
||||||
};
|
unmount: () => Promise.resolve(),
|
||||||
|
update: () => Promise.resolve(),
|
||||||
|
version: "1.0.0",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context: {
|
context: {
|
||||||
api,
|
api,
|
||||||
contributions,
|
contributions,
|
||||||
moduleId: "signage",
|
display: {
|
||||||
permissions: { has: (permission) => options.permissions.includes(permission) },
|
url: options.displayUrl ?? "https://api.example.test/display/signage",
|
||||||
principal: options.principal ?? { kind: "anonymous" },
|
},
|
||||||
realtime: { subscribe: () => ({ close: () => undefined }) },
|
moduleId: "signage",
|
||||||
},
|
permissions: {
|
||||||
mounted,
|
has: (permission) => options.permissions.includes(permission),
|
||||||
requests,
|
},
|
||||||
};
|
principal: options.principal ?? { kind: "anonymous" },
|
||||||
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||||
|
},
|
||||||
|
mounted,
|
||||||
|
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.0",
|
moduleVersion: "0.1.1",
|
||||||
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: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
|
uploads: {
|
||||||
|
batchMaxFiles: 20,
|
||||||
|
concurrency: 3,
|
||||||
|
maxBytes: 26_214_400,
|
||||||
|
maxPixels: 40_000_000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createTimelineStore } from "../src/server/store/drizzle-timelines.js";
|
||||||
|
import type { NewStoredSlot } from "../src/server/store/types.js";
|
||||||
|
|
||||||
|
const TIMELINE_ID = "00000000-0000-4000-8000-000000000001";
|
||||||
|
|
||||||
|
function slot(id: string): NewStoredSlot {
|
||||||
|
return {
|
||||||
|
durationOverrideMs: null,
|
||||||
|
eligibleFrom: null,
|
||||||
|
eligibleUntil: null,
|
||||||
|
enabled: true,
|
||||||
|
id,
|
||||||
|
itemId: "image-1",
|
||||||
|
kind: "pinned-item",
|
||||||
|
presentation: {
|
||||||
|
backgroundColor: null,
|
||||||
|
backgroundDarkened: null,
|
||||||
|
backgroundMode: null,
|
||||||
|
blurPixels: null,
|
||||||
|
foregroundShadow: null,
|
||||||
|
},
|
||||||
|
providerId: "signage.native-images",
|
||||||
|
sourceId: null,
|
||||||
|
timelineId: TIMELINE_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A narrow database contract double for the production timeline store.
|
||||||
|
*
|
||||||
|
* Unlike the service-level memory store, it enforces the shipped PostgreSQL
|
||||||
|
* `signage_timeline_slots_position_check` on every insert and update. That is the boundary the
|
||||||
|
* deployed bug crossed: the old collision-avoidance algorithm used negative positions, so the
|
||||||
|
* actual database rejected a native-image slot before playback ever had content to resolve.
|
||||||
|
*/
|
||||||
|
function constraintDatabase(existing: readonly { id: string; position: number }[]): {
|
||||||
|
readonly database: ModuleDatabase;
|
||||||
|
readonly positions: number[];
|
||||||
|
} {
|
||||||
|
const positions: number[] = [];
|
||||||
|
const enforce = (values: { position?: unknown }): void => {
|
||||||
|
if (typeof values.position === "number") {
|
||||||
|
expect(values.position).toBeGreaterThanOrEqual(0);
|
||||||
|
positions.push(values.position);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const transaction = {
|
||||||
|
insert: () => ({
|
||||||
|
values: async (values: { position?: unknown }) => enforce(values),
|
||||||
|
}),
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
orderBy: async () => existing,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: () => ({
|
||||||
|
set: (values: { position?: unknown }) => ({
|
||||||
|
where: async () => enforce(values),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
database: {
|
||||||
|
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction),
|
||||||
|
} as unknown as ModuleDatabase,
|
||||||
|
positions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("production timeline slot ordering", () => {
|
||||||
|
it("creates the first pinned native image without violating the database position constraint", async () => {
|
||||||
|
const { database, positions } = constraintDatabase([]);
|
||||||
|
|
||||||
|
await createTimelineStore(database).addSlot(slot("00000000-0000-4000-8000-000000000002"));
|
||||||
|
|
||||||
|
expect(positions).toEqual([1, 2, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders through a non-negative temporary range before writing the final order", async () => {
|
||||||
|
const first = "00000000-0000-4000-8000-000000000002";
|
||||||
|
const second = "00000000-0000-4000-8000-000000000003";
|
||||||
|
const { database, positions } = constraintDatabase([
|
||||||
|
{ id: first, position: 0 },
|
||||||
|
{ id: second, position: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await createTimelineStore(database).reorderSlots(TIMELINE_ID, [second, first]);
|
||||||
|
|
||||||
|
expect(positions).toEqual([3, 4, 0, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
|
|||||||
expect(manifest.manifestVersion).toBe(7);
|
expect(manifest.manifestVersion).toBe(7);
|
||||||
expect(manifest.id).toBe("signage");
|
expect(manifest.id).toBe("signage");
|
||||||
expect(manifest.name).toBe("Signage");
|
expect(manifest.name).toBe("Signage");
|
||||||
expect(manifest.version).toBe("0.1.0");
|
expect(manifest.version).toBe("0.1.1");
|
||||||
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
|
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ describe("Signage module manifest", () => {
|
|||||||
|
|
||||||
describe("Signage server runtime", () => {
|
describe("Signage server runtime", () => {
|
||||||
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
||||||
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.0" });
|
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
||||||
@@ -241,7 +241,7 @@ describe("Signage server runtime", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(await response.json()).toEqual({
|
expect(await response.json()).toEqual({
|
||||||
crossfadeMs: 400,
|
crossfadeMs: 400,
|
||||||
moduleVersion: "0.1.0",
|
moduleVersion: "0.1.1",
|
||||||
nativeImageDurationMs: 12_000,
|
nativeImageDurationMs: 12_000,
|
||||||
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
|
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
|
||||||
rendererInputVersion: "1.0.0",
|
rendererInputVersion: "1.0.0",
|
||||||
|
|||||||
@@ -589,6 +589,30 @@ describe("authoritative playback", () => {
|
|||||||
expect(announcements.resolveRequests.length).toBe(callsBefore);
|
expect(announcements.resolveRequests.length).toBe(callsBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the active timeline through a complete playback-service restart", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const before = await service.snapshot(group.group.id);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
clock += 2000;
|
||||||
|
service = createPlaybackService({
|
||||||
|
newId,
|
||||||
|
now: () => clock,
|
||||||
|
registry,
|
||||||
|
setTimer: () => undefined,
|
||||||
|
settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 },
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
const after = await service.snapshot(group.group.id);
|
||||||
|
expect(after.activeTimelineId).toBe(timeline.timeline.id);
|
||||||
|
expect(after.currentSelectionId).toBe(before.currentSelectionId);
|
||||||
|
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||||
|
});
|
||||||
|
|
||||||
it("publishes a compact snapshot, with no payload or timeline in it", async () => {
|
it("publishes a compact snapshot, with no payload or timeline in it", async () => {
|
||||||
const group = await seedGroup();
|
const group = await seedGroup();
|
||||||
const timeline = await seedPattern();
|
const timeline = await seedPattern();
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function createMemoryPlaybackStore(
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteChannelGroup(groupId: string): Promise<void> {
|
deleteChannelGroup(groupId: string, _fallbackChannelId: string): Promise<void> {
|
||||||
groups.delete(groupId);
|
groups.delete(groupId);
|
||||||
playback.delete(groupId);
|
playback.delete(groupId);
|
||||||
for (const [id, channel] of channels) {
|
for (const [id, channel] of channels) {
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ export function createMemorySignageStore(
|
|||||||
...playbackStore,
|
...playbackStore,
|
||||||
...viewerStore,
|
...viewerStore,
|
||||||
|
|
||||||
|
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
|
||||||
|
const removedChannelIds = new Set(
|
||||||
|
[...playbackStore.channels.values()]
|
||||||
|
.filter((channel) => channel.groupId === groupId)
|
||||||
|
.map((channel) => channel.id),
|
||||||
|
);
|
||||||
|
for (const viewer of viewerStore.viewers.values()) {
|
||||||
|
if (viewer.channelId !== null && removedChannelIds.has(viewer.channelId)) {
|
||||||
|
await viewerStore.updateViewer(viewer.id, { channelId: fallbackChannelId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await playbackStore.deleteChannelGroup(groupId, fallbackChannelId);
|
||||||
|
},
|
||||||
|
|
||||||
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
|
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
|
||||||
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
|
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
|
||||||
references.set(
|
references.set(
|
||||||
|
|||||||
@@ -128,6 +128,34 @@ describe("viewers", () => {
|
|||||||
expect(store.viewers.size).toBe(1);
|
expect(store.viewers.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves an assigned display atomically when its channel group is deleted", async () => {
|
||||||
|
const original = (await store.listChannelGroups())[0];
|
||||||
|
if (!original) {
|
||||||
|
throw new Error("Expected the default group.");
|
||||||
|
}
|
||||||
|
const session = await openViewerSession({
|
||||||
|
at: new Date(clock),
|
||||||
|
deviceId: null,
|
||||||
|
newId,
|
||||||
|
store,
|
||||||
|
viewerKey: "kiosk-lobby-0003",
|
||||||
|
});
|
||||||
|
expect(original.channels.some((channel) => channel.id === session.channel.id)).toBe(true);
|
||||||
|
const fallbackGroupId = newId();
|
||||||
|
const fallbackChannelId = newId();
|
||||||
|
await store.createChannelGroup({
|
||||||
|
channelId: fallbackChannelId,
|
||||||
|
channelName: "Default",
|
||||||
|
groupId: fallbackGroupId,
|
||||||
|
name: "Foyer",
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.deleteChannelGroup(original.group.id, fallbackChannelId);
|
||||||
|
|
||||||
|
expect(await store.findChannelGroup(original.group.id)).toBeUndefined();
|
||||||
|
expect((await store.findViewer(session.viewer.id))?.channelId).toBe(fallbackChannelId);
|
||||||
|
});
|
||||||
|
|
||||||
it("stores only the safe device identifier a paired principal carries", async () => {
|
it("stores only the safe device identifier a paired principal carries", async () => {
|
||||||
const session = await openViewerSession({
|
const session = await openViewerSession({
|
||||||
at: new Date(clock),
|
at: new Date(clock),
|
||||||
|
|||||||
Reference in New Issue
Block a user