fix(ui): keep Signage state consistent across tabs, and confirm destructive actions

Activating a timeline from Playback left Timelines showing it as inactive,
because each tab read its own stale copy of the channel groups; operators
read that as activation not sticking. Tabs also mounted every panel afresh, so
each switch flashed a spinner and collapsed the page before regrowing. Panels
now share one stale-while-revalidate resource cache, and Playback follows the
management realtime channel so Now showing tracks the wall.

Deleting an image, a timeline, a slot, a source, or an audience happened on a
single click; each is now a two-step in-place confirmation (DESIGN 21.3).

The audience key was presented as a field to fill in. Audiences are now named
in plain words, explained (images look the same for every audience), and the
content key is derived from the name with an Advanced override. The roster
labels signed-in browsers and lets an offline entry be removed after
confirmation, so test tabs and retired screens no longer accumulate. The
preview keeps nothing in the Dashboard origin's storage, and the narrow-screen
notice no longer names the platform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-12 21:32:04 -05:00
co-authored by Claude Opus 5.5
parent 67d89f1020
commit 7c17282616
16 changed files with 3446 additions and 3034 deletions
+326 -331
View File
@@ -1,29 +1,16 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import {
ActionBar,
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import { ActionBar, Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js";
import type {
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.
@@ -33,325 +20,333 @@ import {
* 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"),
[],
"channel-groups",
);
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
"timelines",
);
const detail = useResource(
() =>
groupId === undefined
? Promise.resolve(undefined)
: api.get<PlaybackDetailView>(`playback/${groupId}`),
[groupId],
groupId === undefined ? undefined : `playback/${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);
useManagementUpdates(context, groupId, () => {
detail.reload();
groups.reload();
});
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 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>
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"
/>
) : (
<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>
)}
{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 ? (
<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 ? (
<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>
);
}
/**
* Follows Signage's management realtime channel, which carries revisions only (ADR 0005), and asks
* for fresh detail when this group's playback actually changed — so "Now showing" follows the wall
* instead of freezing until an operator presses something. One subscription per mounted panel,
* closed when the panel goes away.
*/
function useManagementUpdates(
context: ModuleUiHostContext,
groupId: string | undefined,
onChange: () => void,
): void {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (groupId === undefined) {
return;
}
let seen: string | undefined;
const subscription = context.realtime.subscribe("management-state", {
onSnapshot: (payload) => observe(payload),
onUpdate: (payload) => observe(payload),
});
function observe(payload: unknown): void {
const groups = (payload as { groups?: unknown } | null)?.groups;
if (!Array.isArray(groups)) {
return;
}
const mine = groups.find((group: { groupId?: unknown }) => group?.groupId === groupId) as
| { revision?: unknown; selectionRevision?: unknown }
| undefined;
if (!mine) {
return;
}
const marker = `${String(mine.revision)}:${String(mine.selectionRevision)}`;
if (seen !== undefined && marker !== seen) {
onChangeRef.current();
}
seen = marker;
}
return () => subscription.close();
}, [context, groupId]);
}