Fix/signage playback and displays #2

Merged
Zayin_Admin merged 3 commits from fix/signage-playback-and-displays into main 2026-09-30 02:01:22 +00:00
5 changed files with 654 additions and 299 deletions
Showing only changes of commit 36c1a72363 - Show all commits
+6
View File
@@ -24,8 +24,12 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
*/ */
export function SignageDashboard({ export function SignageDashboard({
context, context,
copyText,
displaySetupUrl,
}: { }: {
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string;
}): 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.
@@ -135,6 +139,8 @@ export function SignageDashboard({
<DisplaysPanel <DisplaysPanel
api={api} api={api}
canManage={mayManageDisplays} canManage={mayManageDisplays}
copyText={copyText}
displaySetupUrl={displaySetupUrl}
onGroupsChanged={() => { onGroupsChanged={() => {
setGroupId(undefined); setGroupId(undefined);
groups.reload(); groups.reload();
+13 -1
View File
@@ -19,7 +19,19 @@ export default defineModuleUi({
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} />); 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(); return () => root.unmount();
}, },
}); });
+276 -199
View File
@@ -1,17 +1,4 @@
import { import { Badge, Button, Field, Panel, Select, TextInput } from "@ekklesia/ui";
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextInput,
} 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";
@@ -20,30 +7,93 @@ import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; 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, onGroupsChanged,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly canManage: boolean; readonly canManage: boolean;
readonly copyText: (value: string) => Promise<void>;
readonly displaySetupUrl: string;
readonly onGroupsChanged?: () => void; 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(() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), []);
[],
);
const viewers = useResource(
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
[],
);
const edit = useAction(() => { const edit = useAction(() => {
groups.reload(); groups.reload();
viewers.reload(); viewers.reload();
@@ -51,30 +101,87 @@ export function DisplaysPanel({
}); });
const viewerEdit = useAction(viewers.reload); const viewerEdit = useAction(viewers.reload);
const [groupName, setGroupName] = useState(""); const [groupName, setGroupName] = useState("");
const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle");
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined); const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ const [channelDraft, setChannelDraft] = useState<{
groupId: string;
key: string;
name: string;
}>({
groupId: "", groupId: "",
key: "", key: "",
name: "", name: "",
}); });
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
useTick(5000); useTick(5000);
const nowMs = Date.now(); const nowMs = Date.now();
const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : [];
const channels = const updateViewer = (viewerId: string, changes: Record<string, string>): void => {
groups.state.status === "ready" viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes));
? groups.state.value.groups.flatMap((group) => };
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
)
: [];
return ( return (
<div className="signage-columns"> <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>
<Panel <Panel
actions={ actions={
canManage ? ( canManage ? (
<div className="signage-inline-form"> <div className="signage-inline-form signage-new-group">
<Field label="New channel group"> <Field label="New channel group">
{(control) => ( {(control) => (
<TextInput <TextInput
@@ -89,46 +196,54 @@ export function DisplaysPanel({
disabled={edit.pending || groupName.trim().length === 0} disabled={edit.pending || groupName.trim().length === 0}
onClick={() => onClick={() =>
edit.run(async () => { edit.run(async () => {
await api.send("POST", "channel-groups", { name: groupName.trim() }); await api.send("POST", "channel-groups", {
name: groupName.trim(),
});
setGroupName(""); setGroupName("");
}) })
} }
variant="primary" variant="primary"
> >
Create Create group
</Button> </Button>
</div> </div>
) : undefined ) : undefined
} }
description="A group is one clock. An audience channel is one rendering of it." description="A channel group shares one playback clock. Each audience channel can receive a different content variant."
title="Channel groups" title="Channel groups and displays"
> >
{renderLoadable( {renderLoadable(
groups.state, groups.state,
(value) => ( (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"> <div className="signage-groups" data-testid="signage-groups">
{value.groups.map((group) => ( {value.groups.map((group) => (
<div className="signage-group" key={group.id}> <article className="signage-group" key={group.id}>
<div className="signage-group__header"> <header className="signage-group__header">
<h4 className="signage-editor__subtitle">{group.name}</h4> <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> <Badge tone="info">{group.playback.transport}</Badge>
{canManage && value.groups.length > 1 ? ( {canManage && value.groups.length > 1 ? (
<Button <Button disabled={edit.pending} onClick={() => setConfirmGroupId(group.id)} variant="danger">
disabled={edit.pending}
onClick={() => setConfirmGroupId(group.id)}
variant="danger"
>
Delete group Delete group
</Button> </Button>
) : canManage ? ( ) : canManage ? (
<Badge tone="neutral">At least one group required</Badge> <Badge tone="neutral">Required group</Badge>
) : undefined} ) : undefined}
</div> </header>
{confirmGroupId === group.id ? ( {confirmGroupId === group.id ? (
<div className="signage-confirm" role="alert"> <div className="signage-confirm" role="alert">
<p> <p>
Delete {group.name}? Its playback history will be removed. Displays assigned Delete {group.name}? Its playback history will be removed. Displays assigned here will move to
here will move to the default channel of another group. the default channel of another group.
</p> </p>
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}> <Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}>
Cancel Cancel
@@ -147,32 +262,91 @@ export function DisplaysPanel({
</Button> </Button>
</div> </div>
) : undefined} ) : undefined}
<ul className="ek-list">
{group.channels.map((channel) => ( <section className="signage-audiences">
<li className="ek-list__item signage-row" key={channel.id}> <div className="signage-audiences__heading">
<span className="signage-row__name">{channel.name}</span> <div>
<Badge tone={channel.isDefault ? "success" : "neutral"}> <span className="signage-hierarchy-label">Audience channels</span>
{channel.key} <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> </Badge>
</div>
</div>
{canManage && !channel.isDefault ? ( {canManage && !channel.isDefault ? (
<Button <Button
disabled={edit.pending} disabled={edit.pending}
onClick={() => onClick={() => edit.run(() => api.send("DELETE", `channels/${channel.id}`))}
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
}
variant="danger" variant="danger"
> >
Remove Remove channel
</Button> </Button>
) : undefined} ) : undefined}
</li> </header>
))} <div className="signage-channel__displays">
</ul> <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 ? ( {canManage ? (
<div className="signage-inline-form" data-testid="signage-add-channel"> <div className="signage-channel-form" data-testid="signage-add-channel">
<div className="signage-channel-form__heading">
<h5>Add an audience channel</h5>
<span>The default channel always remains available.</span>
</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 <Field
hint="Stable lowercase ID sent to content providers for audience-specific variants, e.g. online." hint="Advanced: lowercase ID sent to content providers, e.g. online."
label="Provider audience key" label="Audience key"
> >
{(control) => ( {(control) => (
<TextInput <TextInput
@@ -191,26 +365,6 @@ export function DisplaysPanel({
/> />
)} )}
</Field> </Field>
<Field
hint="Friendly label shown to administrators and used when assigning displays."
label="Dashboard 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>
<Button <Button
disabled={ disabled={
edit.pending || edit.pending ||
@@ -225,125 +379,48 @@ export function DisplaysPanel({
key: channelDraft.key.trim(), key: channelDraft.key.trim(),
name: channelDraft.name.trim(), name: channelDraft.name.trim(),
}); });
setChannelDraft({ groupId: "", key: "", name: "" }); setChannelDraft({
groupId: "",
key: "",
name: "",
});
}) })
} }
variant="primary"
> >
Add audience channel Add channel
</Button> </Button>
</div> </div>
) : undefined} ) : undefined}
</div> </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> </div>
), );
},
{ label: "Loading channel groups…", onRetry: groups.reload }, { label: "Loading channel groups…", onRetry: groups.reload },
)} )}
</Panel> {renderLoadable(viewers.state, () => null, {
label: "Loading displays…",
<Panel onRetry: viewers.reload,
description="Pair a screen through Ekklesia, then name it and choose which audience channel it shows." })}
title="Displays"
>
{canManage ? (
<div className="signage-registration-guide">
<h4 className="signage-editor__subtitle">Register a display</h4>
<ol>
<li>Open the Ekklesia path /display/signage on the screen.</li>
<li>Enter its six-digit code under Display devices in the Dashboard sidebar.</li>
<li>When it appears below, give it a clear name and assign its channel.</li>
</ol>
</div>
) : undefined}
{renderLoadable(
viewers.state,
(value) =>
value.viewers.length === 0 ? (
<EmptyState
description="Open /display/signage on the screen, approve its code under Display devices, and it will appear here automatically."
title="No displays yet"
/>
) : (
<Table label="Display health">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
<TableCell header>State</TableCell>
<TableCell header>Channel</TableCell>
<TableCell header>Last seen</TableCell>
<TableCell header>Offline ready</TableCell>
<TableCell header>Most recent error</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.viewers.map((viewer) => (
<TableRow key={viewer.id}>
<TableCell>
{canManage ? (
<TextInput
aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name}
onBlur={(event) =>
event.target.value !== viewer.name &&
viewerEdit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
name: event.target.value,
}),
)
}
/>
) : (
viewer.name
)}
</TableCell>
<TableCell>
<OnlineBadge online={viewer.online} />
</TableCell>
<TableCell>
{canManage ? (
<Select
aria-label={`Channel for ${viewer.name}`}
onChange={(event) =>
viewerEdit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
channelId: event.target.value,
}),
)
}
value={viewer.channelId ?? ""}
>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{`${channel.groupName} · ${channel.name}`}
</option>
))}
</Select>
) : (
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
)}
</TableCell>
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
<TableCell>
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
</Badge>
</TableCell>
<TableCell>
{viewer.lastError === null ? (
"—"
) : (
<span title={viewer.lastError.detail ?? undefined}>
{viewer.lastError.code}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
{ label: "Loading displays…", onRetry: viewers.reload },
)}
<FailureNote failure={edit.failure} /> <FailureNote failure={edit.failure} />
<FailureNote failure={viewerEdit.failure} /> <FailureNote failure={viewerEdit.failure} />
</Panel> </Panel>
+240 -10
View File
@@ -335,24 +335,112 @@
gap: var(--signage-gap); gap: var(--signage-gap);
} }
.signage-group { .signage-pairing-steps {
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
grid-template-columns: repeat(3, minmax(0, 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.signage-pairing-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
.signage-pairing-actions a {
text-decoration: none;
}
.signage-copy-status {
color: var(--ekklesia-text-secondary);
display: block;
font-size: var(--ekklesia-typography-size-sm);
min-height: 1.25em;
margin-block-start: var(--ekklesia-spacing-xs);
}
.signage-pairing-steps li {
align-items: center;
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-md);
display: flex;
gap: var(--signage-gap-tight);
min-width: 0;
padding: var(--ekklesia-spacing-md); padding: var(--ekklesia-spacing-md);
} }
.signage-pairing-steps__number {
align-items: center;
background: var(--ekklesia-accent-base);
border-radius: var(--ekklesia-radius-pill);
color: var(--ekklesia-accent-foreground);
display: inline-flex;
flex: 0 0 var(--ekklesia-control-height-sm);
font-weight: var(--ekklesia-typography-weight-bold);
height: var(--ekklesia-control-height-sm);
justify-content: center;
}
.signage-display-empty {
align-items: baseline;
color: var(--ekklesia-text-secondary);
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-xs) var(--signage-gap-tight);
margin-block-start: var(--signage-gap-tight);
}
.signage-display-empty strong {
color: var(--ekklesia-text-primary);
}
.signage-new-group > .ek-field {
min-width: 16rem;
}
.signage-group {
background: var(--ekklesia-background-canvas);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--signage-panel-radius);
display: grid;
gap: var(--signage-gap);
padding: var(--ekklesia-spacing-lg);
}
.signage-group__header { .signage-group__header {
align-items: center; align-items: center;
display: flex; display: flex;
gap: var(--signage-gap-tight); gap: var(--signage-gap-tight);
} }
.signage-group__header .ek-button:first-of-type { .signage-group__header > .ek-button {
margin-inline-start: auto; margin-inline-start: auto;
} }
.signage-group__identity {
display: grid;
gap: var(--ekklesia-spacing-2xs);
}
.signage-group__identity h3,
.signage-audiences__heading h4,
.signage-channel__header h5,
.signage-channel-form__heading h5 {
margin: 0;
}
.signage-hierarchy-label {
color: var(--ekklesia-text-muted);
font-size: var(--ekklesia-typography-size-xs);
font-weight: var(--ekklesia-typography-weight-medium);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.signage-confirm { .signage-confirm {
align-items: center; align-items: center;
background: var(--ekklesia-status-danger-background); background: var(--ekklesia-status-danger-background);
@@ -369,18 +457,160 @@
margin: 0; margin: 0;
} }
.signage-registration-guide { .signage-audiences {
background: var(--ekklesia-background-inset); border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
display: grid;
gap: var(--signage-gap);
padding-block-start: var(--signage-gap);
}
.signage-audiences__heading {
align-items: end;
display: flex;
gap: var(--signage-gap);
justify-content: space-between;
}
.signage-audiences__heading > div {
display: grid;
gap: var(--ekklesia-spacing-2xs);
}
.signage-audiences__heading > span {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-channel-list {
display: grid;
gap: var(--signage-gap-tight);
}
.signage-channel {
background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-inline-start: var(--ekklesia-spacing-2xs) solid var(--ekklesia-accent-base);
border-radius: var(--ekklesia-radius-md); border-radius: var(--ekklesia-radius-md);
display: grid; display: grid;
gap: var(--ekklesia-spacing-xs); gap: var(--signage-gap-tight);
margin-block-end: var(--signage-gap); margin-inline-start: var(--ekklesia-spacing-sm);
padding: var(--ekklesia-spacing-md); padding: var(--ekklesia-spacing-md);
} }
.signage-registration-guide ol { .signage-channel__header {
align-items: start;
display: flex;
gap: var(--signage-gap-tight);
justify-content: space-between;
}
.signage-channel__header > div {
display: grid;
gap: var(--ekklesia-spacing-xs);
}
.signage-channel__meta {
align-items: center;
color: var(--ekklesia-text-secondary);
display: flex;
flex-wrap: wrap;
font-size: var(--ekklesia-typography-size-sm);
gap: var(--ekklesia-spacing-xs);
}
.signage-channel__meta code,
.signage-pairing-steps code {
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-sm);
color: var(--ekklesia-text-primary);
padding: var(--ekklesia-spacing-2xs) var(--ekklesia-spacing-xs);
}
.signage-channel__displays {
border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
display: grid;
gap: var(--signage-gap-tight);
padding-block-start: var(--signage-gap-tight);
}
.signage-channel__empty {
color: var(--ekklesia-text-muted);
font-size: var(--ekklesia-typography-size-sm);
margin: 0; margin: 0;
padding-inline-start: var(--ekklesia-spacing-xl); }
.signage-display-row {
align-items: end;
background: var(--ekklesia-background-canvas);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--signage-gap-tight);
grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(14rem, 1.2fr);
padding: var(--ekklesia-spacing-sm);
}
.signage-display-row__identity,
.signage-display-row__health {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-xs);
}
.signage-display-row__identity .ek-control,
.signage-display-row__assignment .ek-field {
width: 100%;
}
.signage-display-row__health {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-display-row__error {
color: var(--ekklesia-text-danger);
}
.signage-channel-form,
.signage-unassigned {
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--signage-gap-tight);
margin-inline-start: var(--ekklesia-spacing-sm);
padding: var(--ekklesia-spacing-md);
}
.signage-channel-form {
align-items: start;
grid-template-columns: minmax(16rem, 2fr) minmax(14rem, 1fr);
}
.signage-channel-form__heading,
.signage-channel-form > .ek-button {
grid-column: 1 / -1;
}
.signage-channel-form__heading {
display: grid;
gap: var(--ekklesia-spacing-2xs);
}
.signage-channel-form__heading span {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-channel-form > .ek-button {
justify-self: start;
}
@media (max-width: 1200px) {
.signage-pairing-steps,
.signage-channel-form,
.signage-display-row {
grid-template-columns: 1fr;
}
} }
.signage-provider__id { .signage-provider__id {
+44 -14
View File
@@ -18,6 +18,21 @@ import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./su
let container: HTMLElement; let container: HTMLElement;
let root: Root; let root: Root;
const DISPLAY_SETUP_URL = "https://dashboard.example.test/display/signage";
const copiedText: string[] = [];
function dashboard(context: Parameters<typeof SignageDashboard>[0]["context"]): React.ReactElement {
return (
<SignageDashboard
context={context}
copyText={(value) => {
copiedText.push(value);
return Promise.resolve();
}}
displaySetupUrl={DISPLAY_SETUP_URL}
/>
);
}
async function render(element: React.ReactElement): Promise<void> { async function render(element: React.ReactElement): Promise<void> {
await act(async () => { await act(async () => {
@@ -131,6 +146,7 @@ const TIMELINE = {
}; };
beforeEach(() => { beforeEach(() => {
copiedText.length = 0;
container = document.createElement("div"); container = document.createElement("div");
document.body.append(container); document.body.append(container);
root = createRoot(container); root = createRoot(container);
@@ -168,7 +184,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain( expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
"welcome", "welcome",
@@ -197,7 +213,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
// The current slide is still legible; the buttons that would be refused are simply not offered. // The current slide is still legible; the buttons that would be refused are simply not offered.
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull(); expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
@@ -220,7 +236,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
expect(container.textContent).toContain("Active: Sunday morning"); expect(container.textContent).toContain("Active: Sunday morning");
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
@@ -262,7 +278,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Timelines", (candidate) => candidate.textContent === "Timelines",
@@ -293,7 +309,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines", (tab) => tab.textContent === "Timelines",
@@ -357,7 +373,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Timelines", (tab) => tab.textContent === "Timelines",
@@ -407,7 +423,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers", (tab) => tab.textContent === "Providers",
@@ -430,7 +446,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(tab) => tab.textContent === "Providers", (tab) => tab.textContent === "Providers",
@@ -476,13 +492,15 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"), [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
); );
expect(container.textContent).toContain("Online"); expect(container.textContent).toContain("Online");
expect(container.textContent).toContain("12 cached"); expect(container.textContent).toContain("12 cached");
const display = container.querySelector("[data-testid='signage-display-viewer-1']");
expect(display?.closest(".signage-channel")?.textContent).toContain("Default");
// A sanitized code, never a stack or a message a browser happened to have. // A sanitized code, never a stack or a message a browser happened to have.
expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
}); });
@@ -521,15 +539,27 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find( [...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === "Displays", (candidate) => candidate.textContent === "Displays",
), ),
); );
expect(container.textContent).toContain("/display/signage"); const setupLink = [...container.querySelectorAll("a")].find(
expect(container.textContent).toContain("Provider audience key"); (candidate) => candidate.textContent?.trim() === "Open display setup",
);
expect(setupLink?.href).toBe(DISPLAY_SETUP_URL);
expect(setupLink?.target).toBe("_blank");
expect(setupLink?.rel).toContain("noopener");
await click(buttonNamed("Copy URL"));
expect(copiedText).toEqual([DISPLAY_SETUP_URL]);
expect(container.textContent).toContain("Display setup URL copied.");
expect(container.textContent).toContain("Channel groups and displays");
expect(container.textContent).toContain("Audience channels");
expect(container.textContent).toContain("Channel name");
expect(container.textContent).toContain("Audience key");
expect(container.textContent).toContain("Advanced: lowercase ID sent to content providers");
await click(buttonNamed("Delete group")); await click(buttonNamed("Delete group"));
expect(deleted).toEqual([]); expect(deleted).toEqual([]);
expect(container.textContent).toContain("Displays assigned here will move"); expect(container.textContent).toContain("Displays assigned here will move");
@@ -564,7 +594,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
await click( await click(
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"), [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
); );
@@ -615,7 +645,7 @@ describe("Signage dashboard", () => {
], ],
}); });
await render(<SignageDashboard context={host.context} />); await render(dashboard(host.context));
expect(container.textContent).toContain("Signage could not load this"); expect(container.textContent).toContain("Signage could not load this");
expect(container.textContent).toContain("The operation could not be completed."); expect(container.textContent).toContain("The operation could not be completed.");