feat: align Signage with platform display and design
This commit is contained in:
+165
-135
@@ -1,5 +1,5 @@
|
||||
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 { DesktopOnly } from "../browser/desktop-gate.js";
|
||||
import { createSignageApi } from "./api.js";
|
||||
@@ -23,144 +23,174 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
||||
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
|
||||
*/
|
||||
export function SignageDashboard({
|
||||
context,
|
||||
copyText,
|
||||
displaySetupUrl,
|
||||
context,
|
||||
copyText,
|
||||
}: {
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly copyText: (value: string) => Promise<void>;
|
||||
readonly displaySetupUrl: string;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly copyText: (value: string) => Promise<void>;
|
||||
}): ReactElement {
|
||||
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.
|
||||
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
||||
// request it will certainly refuse is noise in a log rather than security.
|
||||
const mayView = context.permissions.has("signage.view");
|
||||
const settings = useResource(
|
||||
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
|
||||
[mayView],
|
||||
);
|
||||
const groups = useResource(
|
||||
() =>
|
||||
mayView
|
||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||
: Promise.resolve(undefined),
|
||||
[mayView],
|
||||
);
|
||||
const [tab, setTab] = useState("playback");
|
||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||
const 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.
|
||||
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
||||
// request it will certainly refuse is noise in a log rather than security.
|
||||
const mayView = context.permissions.has("signage.view");
|
||||
const settings = useResource(
|
||||
() =>
|
||||
mayView
|
||||
? api.get<SettingsResponse>("settings")
|
||||
: Promise.resolve(undefined),
|
||||
[mayView],
|
||||
);
|
||||
const groups = useResource(
|
||||
() =>
|
||||
mayView
|
||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||
: Promise.resolve(undefined),
|
||||
[mayView],
|
||||
);
|
||||
const [tab, setTab] = useState("playback");
|
||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||
|
||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
|
||||
const mayControl = context.permissions.has("signage.playback.control");
|
||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||
const mayManageTimelines = context.permissions.has(
|
||||
"signage.timelines.manage",
|
||||
);
|
||||
const mayControl = context.permissions.has("signage.playback.control");
|
||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||
|
||||
if (!mayView) {
|
||||
return (
|
||||
<EmptyState
|
||||
description="Ask an administrator for the signage.view permission."
|
||||
title="No access to Signage"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!mayView) {
|
||||
return (
|
||||
<EmptyState
|
||||
description="Ask an administrator for the signage.view permission."
|
||||
title="No access to Signage"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedGroupId =
|
||||
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;
|
||||
const selectedGroupId =
|
||||
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 (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<DesktopOnly>
|
||||
{renderLoadable(
|
||||
settings.state,
|
||||
(loaded) =>
|
||||
loaded === undefined ? undefined : (
|
||||
<Tabs
|
||||
label="Signage sections"
|
||||
onSelect={setTab}
|
||||
selectedId={tab}
|
||||
tabs={[
|
||||
{
|
||||
content: (
|
||||
<PlaybackPanel
|
||||
api={api}
|
||||
canControl={mayControl}
|
||||
context={context}
|
||||
groupId={selectedGroupId}
|
||||
onGroupChange={setGroupId}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "playback",
|
||||
label: "Playback",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<TimelinesPanel
|
||||
{...(selectedGroup
|
||||
? {
|
||||
activeGroupName: selectedGroup.name,
|
||||
activeTimelineId: selectedGroup.playback.activeTimelineId,
|
||||
}
|
||||
: {})}
|
||||
api={api}
|
||||
canManage={mayManageTimelines}
|
||||
onActivate={
|
||||
mayControl && selectedGroupId !== undefined
|
||||
? async (timelineId) => {
|
||||
await api.send("POST", `playback/${selectedGroupId}/activate`, {
|
||||
timelineId,
|
||||
when: "now",
|
||||
});
|
||||
groups.reload();
|
||||
setTab("playback");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "timelines",
|
||||
label: "Timelines",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
||||
),
|
||||
id: "images",
|
||||
label: "Images",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<DisplaysPanel
|
||||
api={api}
|
||||
canManage={mayManageDisplays}
|
||||
copyText={copyText}
|
||||
displaySetupUrl={displaySetupUrl}
|
||||
onGroupsChanged={() => {
|
||||
setGroupId(undefined);
|
||||
groups.reload();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
id: "displays",
|
||||
label: "Displays",
|
||||
},
|
||||
{
|
||||
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
|
||||
id: "providers",
|
||||
label: "Providers",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
{ label: "Loading Signage…", onRetry: settings.reload },
|
||||
)}
|
||||
</DesktopOnly>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<DesktopOnly>
|
||||
<PageHeader
|
||||
description="Plan visual content, operate playback, and keep every screen connected."
|
||||
eyebrow="Media"
|
||||
title="Signage"
|
||||
/>
|
||||
{renderLoadable(
|
||||
settings.state,
|
||||
(loaded) =>
|
||||
loaded === undefined ? undefined : (
|
||||
<div className="signage-workspace">
|
||||
<Tabs
|
||||
label="Signage sections"
|
||||
onSelect={setTab}
|
||||
selectedId={tab}
|
||||
tabs={[
|
||||
{
|
||||
content: (
|
||||
<PlaybackPanel
|
||||
api={api}
|
||||
canControl={mayControl}
|
||||
context={context}
|
||||
groupId={selectedGroupId}
|
||||
onGroupChange={setGroupId}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "playback",
|
||||
label: "Playback",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<TimelinesPanel
|
||||
{...(selectedGroup
|
||||
? {
|
||||
activeGroupName: selectedGroup.name,
|
||||
activeTimelineId:
|
||||
selectedGroup.playback.activeTimelineId,
|
||||
}
|
||||
: {})}
|
||||
api={api}
|
||||
canManage={mayManageTimelines}
|
||||
onActivate={
|
||||
mayControl && selectedGroupId !== undefined
|
||||
? async (timelineId) => {
|
||||
await api.send(
|
||||
"POST",
|
||||
`playback/${selectedGroupId}/activate`,
|
||||
{
|
||||
timelineId,
|
||||
when: "now",
|
||||
},
|
||||
);
|
||||
groups.reload();
|
||||
setTab("playback");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "timelines",
|
||||
label: "Timelines",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<ImagesPanel
|
||||
api={api}
|
||||
canManage={mayManageAssets}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
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
-20
@@ -14,24 +14,24 @@ import ownStyles from "./styles.css";
|
||||
* rather than bundled (ADR 0039).
|
||||
*/
|
||||
export default defineModuleUi({
|
||||
id: "signage",
|
||||
version: "0.1.1",
|
||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
const displaySetupUrl = new URL("/display/signage", globalThis.location.origin).href;
|
||||
root.render(
|
||||
<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);
|
||||
}}
|
||||
displaySetupUrl={displaySetupUrl}
|
||||
/>,
|
||||
);
|
||||
return () => root.unmount();
|
||||
},
|
||||
id: "signage",
|
||||
version: "0.1.1",
|
||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<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();
|
||||
},
|
||||
});
|
||||
|
||||
+536
-403
@@ -1,78 +1,102 @@
|
||||
import { Badge, Button, Field, Panel, Select, TextInput } from "@ekklesia/ui";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
InlineNotice,
|
||||
Panel,
|
||||
Select,
|
||||
TextInput,
|
||||
} from "@ekklesia/ui";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import type { SignageApi } from "../api.js";
|
||||
import { useAction, useResource, useTick } from "../hooks.js";
|
||||
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
||||
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
|
||||
import {
|
||||
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,
|
||||
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;
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,350 +104,459 @@ function DisplayRow({
|
||||
* time: one group clock, one audience variant per channel, then the screens assigned to it.
|
||||
*/
|
||||
export function DisplaysPanel({
|
||||
api,
|
||||
canManage,
|
||||
copyText,
|
||||
displaySetupUrl,
|
||||
onGroupsChanged,
|
||||
api,
|
||||
canManage,
|
||||
copyText,
|
||||
displaySetupUrl,
|
||||
onGroupsChanged,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly canManage: boolean;
|
||||
readonly copyText: (value: string) => Promise<void>;
|
||||
readonly displaySetupUrl: string;
|
||||
readonly onGroupsChanged?: () => void;
|
||||
readonly api: SignageApi;
|
||||
readonly canManage: boolean;
|
||||
readonly copyText: (value: string) => Promise<void>;
|
||||
readonly displaySetupUrl: string | undefined;
|
||||
readonly onGroupsChanged?: () => void;
|
||||
}): ReactElement {
|
||||
const groups = useResource(() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), []);
|
||||
const viewers = useResource(() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), []);
|
||||
const edit = useAction(() => {
|
||||
groups.reload();
|
||||
viewers.reload();
|
||||
onGroupsChanged?.();
|
||||
});
|
||||
const viewerEdit = useAction(viewers.reload);
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle");
|
||||
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
|
||||
const [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 groups = useResource(
|
||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||
[],
|
||||
);
|
||||
const viewers = useResource(
|
||||
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
||||
[],
|
||||
);
|
||||
const edit = useAction(() => {
|
||||
groups.reload();
|
||||
viewers.reload();
|
||||
onGroupsChanged?.();
|
||||
});
|
||||
const viewerEdit = useAction(viewers.reload);
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">(
|
||||
"idle",
|
||||
);
|
||||
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [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 updateViewer = (viewerId: string, changes: Record<string, string>): void => {
|
||||
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
|
||||
};
|
||||
const updateViewer = (
|
||||
viewerId: string,
|
||||
changes: Record<string, string>,
|
||||
): void => {
|
||||
viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={
|
||||
<div className="signage-pairing-actions">
|
||||
<a
|
||||
className="ek-button ek-focusable ek-button--primary"
|
||||
href={displaySetupUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open display setup
|
||||
</a>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCopyStatus("idle");
|
||||
void copyText(displaySetupUrl).then(
|
||||
() => setCopyStatus("copied"),
|
||||
() => setCopyStatus("failed"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy URL
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description="Connect a screen in three short steps."
|
||||
title="Pair a display"
|
||||
>
|
||||
<ol className="signage-pairing-steps" aria-label="Display pairing steps">
|
||||
<li>
|
||||
<span className="signage-pairing-steps__number">1</span>
|
||||
<span>Open display setup on the screen that will show signage.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="signage-pairing-steps__number">2</span>
|
||||
<span>Enter its six-digit code under Display devices in the Dashboard sidebar.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="signage-pairing-steps__number">3</span>
|
||||
<span>Name the display and choose its audience channel below.</span>
|
||||
</li>
|
||||
</ol>
|
||||
<span className="signage-copy-status" aria-live="polite">
|
||||
{copyStatus === "copied"
|
||||
? "Display setup URL copied."
|
||||
: copyStatus === "failed"
|
||||
? "Could not copy the URL. Copy it from the newly opened setup page."
|
||||
: ""}
|
||||
</span>
|
||||
{viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? (
|
||||
<div className="signage-display-empty">
|
||||
<strong>No displays paired yet</strong>
|
||||
<span>A newly approved screen appears below in its assigned channel.</span>
|
||||
</div>
|
||||
) : undefined}
|
||||
</Panel>
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={
|
||||
displaySetupUrl === undefined ? undefined : (
|
||||
<div className="signage-pairing-actions">
|
||||
<a
|
||||
className="ek-button ek-focusable ek-button--primary"
|
||||
href={displaySetupUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open display setup
|
||||
</a>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCopyStatus("idle");
|
||||
void copyText(displaySetupUrl).then(
|
||||
() => setCopyStatus("copied"),
|
||||
() => setCopyStatus("failed"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Copy URL
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description="Connect a screen in three short steps."
|
||||
title="Pair a display"
|
||||
>
|
||||
<ol
|
||||
className="signage-pairing-steps"
|
||||
aria-label="Display pairing steps"
|
||||
>
|
||||
<li>
|
||||
<span className="signage-pairing-steps__number">1</span>
|
||||
<span>
|
||||
Open display setup on the screen that will show signage.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="signage-pairing-steps__number">2</span>
|
||||
<span>
|
||||
Enter its six-digit code under Display devices in the Dashboard
|
||||
sidebar.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="signage-pairing-steps__number">3</span>
|
||||
<span>Name the display and choose its audience channel below.</span>
|
||||
</li>
|
||||
</ol>
|
||||
{displaySetupUrl === undefined ? (
|
||||
<InlineNotice tone="warning" title="Display setup unavailable">
|
||||
This host did not provide a display setup address.
|
||||
</InlineNotice>
|
||||
) : undefined}
|
||||
<span className="signage-copy-status" aria-live="polite">
|
||||
{copyStatus === "copied"
|
||||
? "Display setup URL copied."
|
||||
: copyStatus === "failed"
|
||||
? "Could not copy the URL. Copy it from the newly opened setup page."
|
||||
: ""}
|
||||
</span>
|
||||
{viewers.state.status === "ready" &&
|
||||
viewers.state.value.viewers.length === 0 ? (
|
||||
<div className="signage-display-empty">
|
||||
<strong>No displays paired yet</strong>
|
||||
<span>
|
||||
A newly approved screen appears below in its assigned channel.
|
||||
</span>
|
||||
</div>
|
||||
) : undefined}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
actions={
|
||||
canManage ? (
|
||||
<div className="signage-inline-form signage-new-group">
|
||||
<Field label="New channel group">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) => setGroupName(event.target.value)}
|
||||
placeholder="Nursery hallway"
|
||||
value={groupName}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={edit.pending || groupName.trim().length === 0}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("POST", "channel-groups", {
|
||||
name: groupName.trim(),
|
||||
});
|
||||
setGroupName("");
|
||||
})
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Create group
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
|
||||
title="Channel groups and displays"
|
||||
>
|
||||
{renderLoadable(
|
||||
groups.state,
|
||||
(value) => {
|
||||
const knownChannelIds = new Set(
|
||||
value.groups.flatMap((group) => group.channels.map((channel) => channel.id)),
|
||||
);
|
||||
const unassigned = readyViewers.filter(
|
||||
(viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId),
|
||||
);
|
||||
return (
|
||||
<div className="signage-groups" data-testid="signage-groups">
|
||||
{value.groups.map((group) => (
|
||||
<article className="signage-group" key={group.id}>
|
||||
<header className="signage-group__header">
|
||||
<div className="signage-group__identity">
|
||||
<span className="signage-hierarchy-label">Channel group</span>
|
||||
<h3>{group.name}</h3>
|
||||
</div>
|
||||
<Badge tone="info">{group.playback.transport}</Badge>
|
||||
{canManage && value.groups.length > 1 ? (
|
||||
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(group.id)} variant="danger">
|
||||
Delete group
|
||||
</Button>
|
||||
) : canManage ? (
|
||||
<Badge tone="neutral">Required group</Badge>
|
||||
) : undefined}
|
||||
</header>
|
||||
{confirmGroupId === group.id ? (
|
||||
<div className="signage-confirm" role="alert">
|
||||
<p>
|
||||
Delete {group.name}? Its playback history will be removed. Displays assigned here will move to
|
||||
the default channel of another group.
|
||||
</p>
|
||||
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("DELETE", `channel-groups/${group.id}`);
|
||||
setConfirmGroupId(undefined);
|
||||
})
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Confirm delete
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
<Panel
|
||||
actions={
|
||||
canManage ? (
|
||||
<div className="signage-inline-form signage-new-group">
|
||||
<Field label="New channel group">
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
onChange={(event) => setGroupName(event.target.value)}
|
||||
placeholder="Nursery hallway"
|
||||
value={groupName}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
disabled={edit.pending || groupName.trim().length === 0}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("POST", "channel-groups", {
|
||||
name: groupName.trim(),
|
||||
});
|
||||
setGroupName("");
|
||||
})
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
Create group
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
|
||||
title="Channel groups and displays"
|
||||
>
|
||||
{renderLoadable(
|
||||
groups.state,
|
||||
(value) => {
|
||||
const knownChannelIds = new Set(
|
||||
value.groups.flatMap((group) =>
|
||||
group.channels.map((channel) => channel.id),
|
||||
),
|
||||
);
|
||||
const unassigned = readyViewers.filter(
|
||||
(viewer) =>
|
||||
viewer.channelId === null ||
|
||||
!knownChannelIds.has(viewer.channelId),
|
||||
);
|
||||
return (
|
||||
<div className="signage-groups" data-testid="signage-groups">
|
||||
{value.groups.map((group) => (
|
||||
<article className="signage-group" key={group.id}>
|
||||
<header className="signage-group__header">
|
||||
<div className="signage-group__identity">
|
||||
<span className="signage-hierarchy-label">
|
||||
Channel group
|
||||
</span>
|
||||
<h3>{group.name}</h3>
|
||||
</div>
|
||||
<Badge tone="info">{group.playback.transport}</Badge>
|
||||
{canManage && value.groups.length > 1 ? (
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() => setConfirmGroupId(group.id)}
|
||||
variant="danger"
|
||||
>
|
||||
Delete group
|
||||
</Button>
|
||||
) : canManage ? (
|
||||
<Badge tone="neutral">Required group</Badge>
|
||||
) : undefined}
|
||||
</header>
|
||||
{confirmGroupId === group.id ? (
|
||||
<div className="signage-confirm" role="alert">
|
||||
<p>
|
||||
Delete {group.name}? Its playback history will be
|
||||
removed. Displays assigned here will move to the
|
||||
default channel of another group.
|
||||
</p>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() => setConfirmGroupId(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send(
|
||||
"DELETE",
|
||||
`channel-groups/${group.id}`,
|
||||
);
|
||||
setConfirmGroupId(undefined);
|
||||
})
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
+330
-262
@@ -1,16 +1,29 @@
|
||||
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 { SignageApi } from "../api.js";
|
||||
import { useAction, useResource, useTick } from "../hooks.js";
|
||||
import type {
|
||||
ChannelGroupView,
|
||||
PlaybackDetailView,
|
||||
SettingsResponse,
|
||||
TimelineSummary,
|
||||
ChannelGroupView,
|
||||
PlaybackDetailView,
|
||||
SettingsResponse,
|
||||
TimelineSummary,
|
||||
} from "../types.js";
|
||||
import { PreviewPanel } from "./PreviewPanel.js";
|
||||
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||
import {
|
||||
FailureNote,
|
||||
formatDuration,
|
||||
formatInstant,
|
||||
renderLoadable,
|
||||
} from "./shared.js";
|
||||
|
||||
/**
|
||||
* Live transport for one channel group.
|
||||
@@ -20,270 +33,325 @@ import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./sh
|
||||
* button, and neither can produce a state the other did not ask for.
|
||||
*/
|
||||
export function PlaybackPanel({
|
||||
api,
|
||||
context,
|
||||
settings,
|
||||
canControl,
|
||||
groupId,
|
||||
onGroupChange,
|
||||
api,
|
||||
context,
|
||||
settings,
|
||||
canControl,
|
||||
groupId,
|
||||
onGroupChange,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canControl: boolean;
|
||||
readonly groupId: string | undefined;
|
||||
readonly onGroupChange: (groupId: string) => void;
|
||||
readonly api: SignageApi;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canControl: boolean;
|
||||
readonly groupId: string | undefined;
|
||||
readonly onGroupChange: (groupId: string) => void;
|
||||
}): ReactElement {
|
||||
const groups = useResource(
|
||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||
[],
|
||||
);
|
||||
const timelines = useResource(
|
||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||
[],
|
||||
);
|
||||
const detail = useResource(
|
||||
() =>
|
||||
groupId === undefined
|
||||
? Promise.resolve(undefined)
|
||||
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
||||
[groupId],
|
||||
);
|
||||
const command = useAction(() => {
|
||||
detail.reload();
|
||||
groups.reload();
|
||||
});
|
||||
const [activateId, setActivateId] = useState("");
|
||||
const previewChannelId =
|
||||
groups.state.status === "ready"
|
||||
? (groups.state.value.groups
|
||||
.find((group) => group.id === groupId)
|
||||
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
||||
: undefined;
|
||||
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
||||
useTick(1000);
|
||||
const groups = useResource(
|
||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||
[],
|
||||
);
|
||||
const timelines = useResource(
|
||||
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||
[],
|
||||
);
|
||||
const detail = useResource(
|
||||
() =>
|
||||
groupId === undefined
|
||||
? Promise.resolve(undefined)
|
||||
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
||||
[groupId],
|
||||
);
|
||||
const command = useAction(() => {
|
||||
detail.reload();
|
||||
groups.reload();
|
||||
});
|
||||
const [activateId, setActivateId] = useState("");
|
||||
const previewChannelId =
|
||||
groups.state.status === "ready"
|
||||
? (groups.state.value.groups
|
||||
.find((group) => group.id === groupId)
|
||||
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
||||
: undefined;
|
||||
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
||||
useTick(1000);
|
||||
|
||||
function run(path: string, body?: unknown): void {
|
||||
if (groupId === undefined) {
|
||||
return;
|
||||
}
|
||||
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
|
||||
}
|
||||
function run(path: string, body?: unknown): void {
|
||||
if (groupId === undefined) {
|
||||
return;
|
||||
}
|
||||
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
|
||||
}
|
||||
|
||||
function timelineLabel(timelineId: string): string {
|
||||
if (timelines.state.status !== "ready") {
|
||||
return timelineId;
|
||||
}
|
||||
return (
|
||||
timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
|
||||
timelineId
|
||||
);
|
||||
}
|
||||
function timelineLabel(timelineId: string): string {
|
||||
if (timelines.state.status !== "ready") {
|
||||
return timelineId;
|
||||
}
|
||||
return (
|
||||
timelines.state.value.timelines.find(
|
||||
(timeline) => timeline.id === timelineId,
|
||||
)?.name ?? timelineId
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="signage-columns">
|
||||
<Panel
|
||||
actions={renderLoadable(
|
||||
groups.state,
|
||||
(value) => (
|
||||
<Field label="Channel group">
|
||||
{(control) => (
|
||||
<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">
|
||||
{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>
|
||||
return (
|
||||
<div className="signage-columns signage-playback-layout">
|
||||
<Panel
|
||||
actions={renderLoadable(
|
||||
groups.state,
|
||||
(value) => (
|
||||
<Field label="Channel group">
|
||||
{(control) => (
|
||||
<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">
|
||||
{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>
|
||||
|
||||
{value.current === null ? (
|
||||
<EmptyState
|
||||
description="Nothing is currently resolvable. A display shows its safe slate."
|
||||
title="Nothing on screen"
|
||||
/>
|
||||
) : (
|
||||
<dl className="signage-playback__current" data-testid="signage-current">
|
||||
<dt>Provider</dt>
|
||||
<dd>{value.current.providerId}</dd>
|
||||
<dt>Item</dt>
|
||||
<dd>{value.current.itemId}</dd>
|
||||
<dt>Renderer</dt>
|
||||
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
||||
<dt>Duration</dt>
|
||||
<dd>{formatDuration(value.current.durationMs)}</dd>
|
||||
<dt>Remaining</dt>
|
||||
<dd data-testid="signage-remaining">
|
||||
{value.snapshot.remainingMs === null
|
||||
? "—"
|
||||
: formatDuration(value.snapshot.remainingMs)}
|
||||
</dd>
|
||||
<dt>Started</dt>
|
||||
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
{value.current === null ? (
|
||||
<EmptyState
|
||||
description="Nothing is currently resolvable. A display shows its safe slate."
|
||||
title="Nothing on screen"
|
||||
/>
|
||||
) : (
|
||||
<section className="signage-playback__now">
|
||||
<h3>Now showing</h3>
|
||||
<dl
|
||||
className="signage-playback__current"
|
||||
data-testid="signage-current"
|
||||
>
|
||||
<dt>Provider</dt>
|
||||
<dd>{value.current.providerId}</dd>
|
||||
<dt>Item</dt>
|
||||
<dd>{value.current.itemId}</dd>
|
||||
<dt>Renderer</dt>
|
||||
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
||||
<dt>Duration</dt>
|
||||
<dd>{formatDuration(value.current.durationMs)}</dd>
|
||||
<dt>Remaining</dt>
|
||||
<dd data-testid="signage-remaining">
|
||||
{value.snapshot.remainingMs === null
|
||||
? "—"
|
||||
: formatDuration(value.snapshot.remainingMs)}
|
||||
</dd>
|
||||
<dt>Started</dt>
|
||||
<dd>
|
||||
{formatInstant(
|
||||
value.current.startedAtMs,
|
||||
settings.timezone,
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canControl ? (
|
||||
<div className="signage-playback__controls" data-testid="signage-controls">
|
||||
<Button disabled={command.pending} onClick={() => run("previous")}>
|
||||
Previous
|
||||
</Button>
|
||||
{value.snapshot.transport === "paused" ? (
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("resume")}
|
||||
variant="primary"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={command.pending} onClick={() => run("pause")}>
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={command.pending} onClick={() => run("next")}>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
|
||||
variant={value.snapshot.blackout ? "primary" : "secondary"}
|
||||
>
|
||||
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
||||
</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>
|
||||
</div>
|
||||
) : undefined}
|
||||
{canControl ? (
|
||||
<ActionBar
|
||||
className="signage-playback__controls"
|
||||
data-testid="signage-controls"
|
||||
label="Playback controls"
|
||||
>
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("previous")}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
{value.snapshot.transport === "paused" ? (
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("resume")}
|
||||
variant="primary"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("pause")}
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() => run("next")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
disabled={command.pending}
|
||||
onClick={() =>
|
||||
run("blackout", { on: !value.snapshot.blackout })
|
||||
}
|
||||
variant={
|
||||
value.snapshot.blackout ? "primary" : "secondary"
|
||||
}
|
||||
>
|
||||
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
||||
</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}
|
||||
|
||||
{canControl ? (
|
||||
<div className="signage-playback__activate">
|
||||
{renderLoadable(
|
||||
timelines.state,
|
||||
(loaded) => (
|
||||
<>
|
||||
<Field label="Activate timeline">
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
onChange={(event) => setActivateId(event.target.value)}
|
||||
value={activateId}
|
||||
>
|
||||
<option value="">Choose a timeline…</option>
|
||||
{loaded.timelines.map((timeline) => (
|
||||
<option key={timeline.id} value={timeline.id}>
|
||||
{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}
|
||||
{canControl ? (
|
||||
<div className="signage-playback__activate">
|
||||
{renderLoadable(
|
||||
timelines.state,
|
||||
(loaded) => (
|
||||
<>
|
||||
<Field label="Activate timeline">
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
onChange={(event) =>
|
||||
setActivateId(event.target.value)
|
||||
}
|
||||
value={activateId}
|
||||
>
|
||||
<option value="">Choose a timeline…</option>
|
||||
{loaded.timelines.map((timeline) => (
|
||||
<option
|
||||
key={timeline.id}
|
||||
value={timeline.id}
|
||||
>
|
||||
{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}
|
||||
|
||||
{value.history.length === 0 ? undefined : (
|
||||
<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>
|
||||
{value.history.length === 0 ? undefined : (
|
||||
<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>
|
||||
);
|
||||
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
Badge,
|
||||
EmptyState,
|
||||
Panel,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Badge,
|
||||
EmptyState,
|
||||
Panel,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@ekklesia/ui";
|
||||
import type { ReactElement } from "react";
|
||||
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".
|
||||
*/
|
||||
export function ProvidersPanel({
|
||||
api,
|
||||
context,
|
||||
settings,
|
||||
api,
|
||||
context,
|
||||
settings,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly api: SignageApi;
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly settings: SettingsResponse;
|
||||
}): ReactElement {
|
||||
const providers = useResource(
|
||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||
[],
|
||||
);
|
||||
const published = new Set(
|
||||
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
|
||||
);
|
||||
const providers = useResource(
|
||||
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||
[],
|
||||
);
|
||||
const published = new Set(
|
||||
context.contributions
|
||||
.list(SLIDE_RENDERER_CAPABILITY)
|
||||
.map((contribution) => contribution.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
|
||||
title="Content providers"
|
||||
>
|
||||
{renderLoadable(
|
||||
providers.state,
|
||||
(value) =>
|
||||
value.providers.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Signage works on its own with native images. Install a content module to add more."
|
||||
title="No content providers registered"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Provider health">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Provider</TableCell>
|
||||
<TableCell header>Contract</TableCell>
|
||||
<TableCell header>Renderer</TableCell>
|
||||
<TableCell header>Health</TableCell>
|
||||
<TableCell header>Last success</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.providers.map((provider) => {
|
||||
const rendererPublished = published.has(provider.renderer.contributionId);
|
||||
const isNative = provider.renderer.contributionId === "signage.native-image";
|
||||
return (
|
||||
<TableRow key={provider.providerId}>
|
||||
<TableCell>
|
||||
<span className="signage-provider__id">{provider.providerId}</span>
|
||||
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
||||
</TableCell>
|
||||
<TableCell>{provider.contractVersion}</TableCell>
|
||||
<TableCell>
|
||||
{isNative ? (
|
||||
<Badge tone="success">Signage renders this</Badge>
|
||||
) : rendererPublished ? (
|
||||
<Badge tone="success">{provider.renderer.contributionId}</Badge>
|
||||
) : (
|
||||
<Badge tone="warning">Renderer not published</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{provider.consecutiveFailures === 0 ? (
|
||||
<Badge tone="success">Healthy</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">
|
||||
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatInstant(provider.lastSuccessAt, settings.timezone)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading providers…", onRetry: providers.reload },
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
return (
|
||||
<Panel
|
||||
actions={
|
||||
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>
|
||||
}
|
||||
description="Running content sources available to timelines."
|
||||
title="Content providers"
|
||||
>
|
||||
{renderLoadable(
|
||||
providers.state,
|
||||
(value) =>
|
||||
value.providers.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Signage works on its own with native images. Install a content module to add more."
|
||||
title="No content providers registered"
|
||||
/>
|
||||
) : (
|
||||
<Table label="Provider health">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell header>Provider</TableCell>
|
||||
<TableCell header>Contract</TableCell>
|
||||
<TableCell header>Renderer</TableCell>
|
||||
<TableCell header>Health</TableCell>
|
||||
<TableCell header>Last success</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.providers.map((provider) => {
|
||||
const rendererPublished = published.has(
|
||||
provider.renderer.contributionId,
|
||||
);
|
||||
const isNative =
|
||||
provider.renderer.contributionId === "signage.native-image";
|
||||
return (
|
||||
<TableRow key={provider.providerId}>
|
||||
<TableCell>
|
||||
<span className="signage-provider__id">
|
||||
{provider.providerId}
|
||||
</span>
|
||||
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
||||
</TableCell>
|
||||
<TableCell>{provider.contractVersion}</TableCell>
|
||||
<TableCell>
|
||||
{isNative ? (
|
||||
<Badge tone="info">Built in</Badge>
|
||||
) : rendererPublished ? (
|
||||
<Badge tone="success">
|
||||
{provider.renderer.contributionId}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="warning">Renderer not published</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{provider.consecutiveFailures === 0 ? (
|
||||
<Badge tone="success">Healthy</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">
|
||||
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatInstant(
|
||||
provider.lastSuccessAt,
|
||||
settings.timezone,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading providers…", onRetry: providers.reload },
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
+527
-453
File diff suppressed because it is too large
Load Diff
+441
-379
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user