feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
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,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly settings: SettingsResponse;
|
||||
readonly canManage: boolean;
|
||||
readonly onActivate: ((timelineId: string) => void) | undefined;
|
||||
}): 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();
|
||||
});
|
||||
|
||||
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>
|
||||
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
|
||||
</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 onClick={() => onActivate(timeline.id)} variant="primary">
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
{ label: "Loading timelines…", onRetry: timelines.reload },
|
||||
)}
|
||||
<FailureNote failure={create.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;
|
||||
}
|
||||
Reference in New Issue
Block a user