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) | 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(undefined); const [newName, setNewName] = useState(""); const create = useAction(() => { setNewName(""); timelines.reload(); }); const activation = useAction(); return (
{(control) => ( setNewName(event.target.value)} placeholder="Sunday morning" value={newName} /> )}
) : undefined } description="Saved patterns an operator builds on demand. Signage ships none." title="Timelines" > {renderLoadable( timelines.state, (value) => value.timelines.length === 0 ? ( ) : ( Name Eligible Revision Actions {value.timelines.map((timeline) => (
{timeline.id === activeTimelineId ? ( {activeGroupName ? `Active in ${activeGroupName}` : "Active"} ) : undefined}
{timeline.eligibleFrom === null && timeline.eligibleUntil === null ? "Always" : `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`} {timeline.revision} {onActivate === undefined ? undefined : ( )}
))}
), { label: "Loading timelines…", onRetry: timelines.reload }, )} {selectedId === undefined ? undefined : ( setSelectedId(undefined)} providers={providers.state.status === "ready" ? providers.state.value.providers : []} settings={settings} timelineId={selectedId} /> )} ); } 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(`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([]); const [items, setItems] = useState([]); 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 ( Close} description="Slots run in order and repeat. Repeating one source is one rotation, not several." title="Timeline pattern" > {renderLoadable( detail.state, (value) => (

{value.timeline.name}

{canManage ? (
{(control) => ( )} {sources.map((source) => ( ))} {items.slice(0, 20).map((item) => ( ))}
) : undefined} {value.sources.length === 0 ? undefined : (

Configured sources

{value.sources.map((source) => (
{source.label} {source.providerId} {canManage ? ( <> edit.run(() => api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`), ) } /> ) : undefined}
))}
)} {value.slots.length === 0 ? ( ) : ( # Content Duration Enabled Order {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 ( {slot.position + 1} {slot.kind === "provider-source" ? (source?.label ?? "Removed source") : (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)} {slot.durationOverrideMs === null ? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)` : formatDuration(slot.durationOverrideMs)} edit.run(() => api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, { enabled: event.target.checked, }), ) } /> {canManage ? (
edit.run(() => api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`), ) } />
) : undefined}
); })}
)} {canManage ? (
edit.run(async () => { await api.send("DELETE", `timelines/${timelineId}`); onClose(); }) } testId="signage-timeline-delete" />
) : undefined}
), { label: "Loading timeline…", onRetry: detail.reload }, )}
); } 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; }