566 lines
15 KiB
TypeScript
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;
|
|
}
|