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]>
511 lines
18 KiB
TypeScript
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;
|
|
}
|