Files
Signage/src/ui/panels/TimelinesPanel.tsx
T
ekklesiaandClaude Opus 5.5 7c17282616 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]>
2026-10-12 21:32:04 -05:00

511 lines
18 KiB
TypeScript

import {
Badge,
Button,
Checkbox,
EmptyState,
Field,
Panel,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextInput,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js";
import { useAction, useResource } from "../hooks.js";
import type {
ImageRecord,
ProviderHealth,
ProviderItemDescriptor,
ProviderSourceDescriptor,
SettingsResponse,
TimelineDetail,
TimelineSummary,
} from "../types.js";
import {
ConfirmButton,
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
const NATIVE_PROVIDER_ID = "signage.native-images";
/**
* The timeline editor.
*
* The pattern an operator arranges here is the product: an ordered list of slots, each either a
* provider source that asks for the next item or one pinned item. Repeating a source is how a loop
* gets three announcements in a row — the five occurrences share one rotation, and this screen makes
* that visible by naming the source on every row rather than hiding it.
*/
export function TimelinesPanel({
api,
settings,
canManage,
onActivate,
activeTimelineId,
activeGroupName,
}: {
readonly api: SignageApi;
readonly settings: SettingsResponse;
readonly canManage: boolean;
readonly onActivate: ((timelineId: string) => Promise<void>) | undefined;
readonly activeTimelineId?: string | null;
readonly activeGroupName?: string;
}): ReactElement {
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
"timelines",
);
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[],
"providers",
);
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
const [newName, setNewName] = useState("");
const create = useAction(() => {
setNewName("");
timelines.reload();
});
const activation = useAction();
return (
<div className="signage-columns">
<Panel
actions={
canManage ? (
<div className="signage-inline-form">
<Field label="New timeline">
{(control) => (
<TextInput
{...control}
onChange={(event) => setNewName(event.target.value)}
placeholder="Sunday morning"
value={newName}
/>
)}
</Field>
<Button
disabled={create.pending || newName.trim().length === 0}
onClick={() =>
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
}
variant="primary"
>
Create
</Button>
</div>
) : undefined
}
description="Saved patterns an operator builds on demand. Signage ships none."
title="Timelines"
>
{renderLoadable(
timelines.state,
(value) =>
value.timelines.length === 0 ? (
<EmptyState
description="Create one and add slots to it. There are no presets, on purpose."
title="No timelines yet"
/>
) : (
<Table label="Saved timelines">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
<TableCell header>Eligible</TableCell>
<TableCell header>Revision</TableCell>
<TableCell header>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.timelines.map((timeline) => (
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
<TableCell>
<div className="signage-timeline-name">
<Button
className="signage-timeline-select"
onClick={() => setSelectedId(timeline.id)}
variant="quiet"
>
{timeline.name}
</Button>
{timeline.id === activeTimelineId ? (
<Badge tone="success">
{activeGroupName ? `Active in ${activeGroupName}` : "Active"}
</Badge>
) : undefined}
</div>
</TableCell>
<TableCell>
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
? "Always"
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
</TableCell>
<TableCell>{timeline.revision}</TableCell>
<TableCell>
{onActivate === undefined ? undefined : (
<Button
disabled={activation.pending || timeline.id === activeTimelineId}
onClick={() => activation.run(() => onActivate(timeline.id))}
variant="primary"
>
{timeline.id === activeTimelineId ? "Active" : "Activate"}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
{ label: "Loading timelines…", onRetry: timelines.reload },
)}
<FailureNote failure={create.failure} />
<FailureNote failure={activation.failure} />
</Panel>
{selectedId === undefined ? undefined : (
<TimelineEditor
api={api}
canManage={canManage}
key={selectedId}
onChanged={timelines.reload}
onClose={() => setSelectedId(undefined)}
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
settings={settings}
timelineId={selectedId}
/>
)}
</div>
);
}
function TimelineEditor({
api,
canManage,
onChanged,
onClose,
providers,
settings,
timelineId,
}: {
readonly api: SignageApi;
readonly canManage: boolean;
readonly onChanged: () => void;
readonly onClose: () => void;
readonly providers: readonly ProviderHealth[];
readonly settings: SettingsResponse;
readonly timelineId: string;
}): ReactElement {
const detail = useResource(
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
[timelineId],
`timelines/${timelineId}`,
);
const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("images"),
[],
"images",
);
const edit = useAction(() => {
detail.reload();
onChanged();
});
const [providerId, setProviderId] = useState("");
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
function loadProvider(nextProviderId: string): void {
setProviderId(nextProviderId);
setSources([]);
setItems([]);
if (nextProviderId === "") {
return;
}
void api
.get<{ sources: readonly ProviderSourceDescriptor[] }>(
`providers/${encodeURIComponent(nextProviderId)}/sources`,
)
.then((response) => setSources(response.sources))
.catch(() => setSources([]));
void api
.get<{ items: readonly ProviderItemDescriptor[] }>(
`providers/${encodeURIComponent(nextProviderId)}/items`,
)
.then((response) => setItems(response.items))
.catch(() => setItems([]));
}
return (
<Panel
actions={<Button onClick={onClose}>Close</Button>}
description="Slots run in order and repeat. Repeating one source is one rotation, not several."
title="Timeline pattern"
>
{renderLoadable(
detail.state,
(value) => (
<div className="signage-editor" data-testid="signage-timeline-editor">
<h3 className="signage-editor__title">{value.timeline.name}</h3>
{canManage ? (
<div className="signage-editor__add" data-testid="signage-add-source">
<Field
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
label="Content provider"
>
{(control) => (
<Select
{...control}
onChange={(event) => loadProvider(event.target.value)}
value={providerId}
>
<option value="">Choose a provider…</option>
{providers.map((provider) => (
<option key={provider.providerId} value={provider.providerId}>
{provider.providerId}
</option>
))}
</Select>
)}
</Field>
{sources.map((source) => (
<Button
disabled={edit.pending}
key={source.id}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/sources`, {
label: source.label,
providerId,
sourceId: source.id,
}),
)
}
>
{`Add source: ${source.label}`}
</Button>
))}
{items.slice(0, 20).map((item) => (
<Button
disabled={edit.pending}
key={item.id}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, {
itemId: item.id,
providerId,
}),
)
}
>
{`Pin item: ${item.label}`}
</Button>
))}
</div>
) : undefined}
{value.sources.length === 0 ? undefined : (
<div className="signage-editor__sources">
<h4 className="signage-editor__subtitle">Configured sources</h4>
{value.sources.map((source) => (
<div className="signage-editor__source" key={source.id}>
<span>{source.label}</span>
<Badge tone="info">{source.providerId}</Badge>
{canManage ? (
<>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/slots`, {
sourceId: source.id,
}),
)
}
>
Add slot
</Button>
<ConfirmButton
confirmLabel="Remove this source"
disabled={edit.pending}
label="Remove"
onConfirm={() =>
edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
)
}
/>
</>
) : undefined}
</div>
))}
</div>
)}
{value.slots.length === 0 ? (
<EmptyState
description="Add a provider source or pin an image, then repeat it as often as the loop needs."
title="This timeline has no slots"
/>
) : (
<Table label="Timeline slots">
<TableHead>
<TableRow>
<TableCell header>#</TableCell>
<TableCell header>Content</TableCell>
<TableCell header>Duration</TableCell>
<TableCell header>Enabled</TableCell>
<TableCell header>Order</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.slots.map((slot, index) => {
const source = value.sources.find(
(candidate) => candidate.id === slot.sourceId,
);
const image =
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready"
? images.state.value.images.find(
(candidate) => candidate.id === slot.itemId,
)
: undefined;
return (
<TableRow key={slot.id}>
<TableCell>{slot.position + 1}</TableCell>
<TableCell>
{slot.kind === "provider-source"
? (source?.label ?? "Removed source")
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
</TableCell>
<TableCell>
{slot.durationOverrideMs === null
? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)`
: formatDuration(slot.durationOverrideMs)}
</TableCell>
<TableCell>
<Checkbox
checked={slot.enabled}
disabled={!canManage || edit.pending}
label={slot.enabled ? "On" : "Off"}
onChange={(event) =>
edit.run(() =>
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
enabled: event.target.checked,
}),
)
}
/>
</TableCell>
<TableCell>
{canManage ? (
<div className="signage-editor__order">
<Button
disabled={index === 0 || edit.pending}
onClick={() =>
edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, {
slotIds: swap(
value.slots.map((candidate) => candidate.id),
index,
index - 1,
),
}),
)
}
>
Up
</Button>
<Button
disabled={index === value.slots.length - 1 || edit.pending}
onClick={() =>
edit.run(() =>
api.send("PUT", `timelines/${timelineId}/slot-order`, {
slotIds: swap(
value.slots.map((candidate) => candidate.id),
index,
index + 1,
),
}),
)
}
>
Down
</Button>
<ConfirmButton
confirmLabel="Remove this slot"
disabled={edit.pending}
label="Remove"
onConfirm={() =>
edit.run(() =>
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
)
}
/>
</div>
) : undefined}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{canManage ? (
<div className="signage-editor__footer">
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send("POST", `timelines/${timelineId}/duplicate`, {
name: `${value.timeline.name} (copy)`,
}),
)
}
>
Duplicate
</Button>
<ConfirmButton
confirmLabel={`Delete ${value.timeline.name}`}
disabled={edit.pending}
label="Delete timeline"
onConfirm={() =>
edit.run(async () => {
await api.send("DELETE", `timelines/${timelineId}`);
onClose();
})
}
testId="signage-timeline-delete"
/>
</div>
) : undefined}
</div>
),
{ label: "Loading timeline…", onRetry: detail.reload },
)}
<FailureNote failure={edit.failure} />
</Panel>
);
}
function swap(ids: readonly string[], from: number, to: number): string[] {
const next = [...ids];
const moved = next[from];
const target = next[to];
if (moved === undefined || target === undefined) {
return next;
}
next[from] = target;
next[to] = moved;
return next;
}