Files
Signage/src/ui/panels/TimelinesPanel.tsx
T

566 lines
15 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 {
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"),
[],
);
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("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],
);
const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("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>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send(
"DELETE",
`timelines/${timelineId}/sources/${source.id}`,
),
)
}
variant="danger"
>
Remove
</Button>
</>
) : 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>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() =>
api.send(
"DELETE",
`timelines/${timelineId}/slots/${slot.id}`,
),
)
}
variant="danger"
>
Remove
</Button>
</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>
<Button
disabled={edit.pending}
onClick={() =>
edit.run(async () => {
await api.send("DELETE", `timelines/${timelineId}`);
onClose();
})
}
variant="danger"
>
Delete timeline
</Button>
</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;
}