feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui";
import { type ReactElement, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js";
import { useResource } from "./hooks.js";
import { DisplaysPanel } from "./panels/DisplaysPanel.js";
import { ImagesPanel } from "./panels/ImagesPanel.js";
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
import { ProvidersPanel } from "./panels/ProvidersPanel.js";
import { renderLoadable } from "./panels/shared.js";
import { TimelinesPanel } from "./panels/TimelinesPanel.js";
import type { ChannelGroupView, SettingsResponse } from "./types.js";
/**
* The Signage management screen.
*
* Host-neutral by construction: every value comes from the host context, and nothing here hardcodes
* a URL, an origin, or a global. Presentation is entirely the platform's shared elements plus
* Signage's own stylesheet, so this looks like the rest of Ekklesia without copying a colour.
*
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
*/
export function SignageDashboard({
context,
}: {
readonly context: ModuleUiHostContext;
}): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
// A hidden screen is a usability choice; the server is what actually refuses, and making a
// request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view");
const settings = useResource(
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
[mayView],
);
const groups = useResource(
() =>
mayView
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage");
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
const mayControl = context.permissions.has("signage.playback.control");
const mayManageDisplays = context.permissions.has("signage.displays.manage");
if (!mayView) {
return (
<EmptyState
description="Ask an administrator for the signage.view permission."
title="No access to Signage"
/>
);
}
const selectedGroupId =
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
return (
<>
<EkklesiaUiStyles />
<DesktopOnly>
{renderLoadable(
settings.state,
(loaded) =>
loaded === undefined ? undefined : (
<Tabs
label="Signage sections"
onSelect={setTab}
selectedId={tab}
tabs={[
{
content: (
<PlaybackPanel
api={api}
canControl={mayControl}
context={context}
groupId={selectedGroupId}
onGroupChange={setGroupId}
settings={loaded}
/>
),
id: "playback",
label: "Playback",
},
{
content: (
<TimelinesPanel
api={api}
canManage={mayManageTimelines}
onActivate={
mayControl && selectedGroupId !== undefined
? (timelineId) => {
void api
.send("POST", `playback/${selectedGroupId}/activate`, {
timelineId,
when: "now",
})
.then(() => setTab("playback"))
.catch(() => setTab("playback"));
}
: undefined
}
settings={loaded}
/>
),
id: "timelines",
label: "Timelines",
},
{
content: (
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
),
id: "images",
label: "Images",
},
{
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
id: "displays",
label: "Displays",
},
{
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
id: "providers",
label: "Providers",
},
]}
/>
),
{ label: "Loading Signage…", onRetry: settings.reload },
)}
</DesktopOnly>
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import type { ModuleUiApiClient } from "@ekklesia/module-ui-sdk";
/**
* Signage's dashboard talking to Signage's own backend.
*
* Every path here is **relative to this module's own API root**, and the host resolves it. Nothing
* in this file builds `/api/v1/modules/signage/...`: that prefix is Ekklesia's private deployment
* detail, and a UI that wrote it would be welded to Ekklesia and unhostable anywhere else.
*
* Failures become one small, typed shape. The server already sanitizes what it says; this keeps a
* network error and a refusal indistinguishable in the places where they should be — a screen that
* cannot load — and distinguishable where they matter, in a form that can show *why* a save was
* refused.
*/
export interface SignageApiFailure {
readonly code: string;
readonly message: string;
readonly status: number;
readonly detail?: Readonly<Record<string, number | string | null>> | undefined;
}
export class SignageRequestError extends Error {
readonly failure: SignageApiFailure;
constructor(failure: SignageApiFailure) {
super(failure.message);
this.name = "SignageRequestError";
this.failure = failure;
}
}
const GENERIC: SignageApiFailure = {
code: "SIGNAGE_REQUEST_FAILED",
message: "Signage could not be reached.",
status: 0,
};
async function readFailure(response: Response): Promise<SignageApiFailure> {
try {
const body = (await response.json()) as {
code?: unknown;
message?: unknown;
detail?: unknown;
};
if (typeof body.code === "string" && typeof body.message === "string") {
return {
code: body.code,
...(typeof body.detail === "object" && body.detail !== null
? { detail: body.detail as SignageApiFailure["detail"] }
: {}),
message: body.message,
status: response.status,
};
}
} catch {
// A response that is not Signage's own error shape tells the operator nothing useful, and
// guessing at it would put someone else's words on the screen.
}
return { ...GENERIC, status: response.status };
}
export interface SignageApi {
get<Result>(path: string): Promise<Result>;
send<Result>(
method: "POST" | "PATCH" | "PUT" | "DELETE",
path: string,
body?: unknown,
): Promise<Result>;
/** One image, one request. The queue that drives this lives in `browser/uploads.ts`. */
upload<Result>(path: string, bytes: Blob, contentType: string): Promise<Result>;
}
export function createSignageApi(client: ModuleUiApiClient): SignageApi {
async function request<Result>(path: string, init: RequestInit | undefined): Promise<Result> {
let response: Response;
try {
response = await client.request(path, init);
} catch {
throw new SignageRequestError(GENERIC);
}
if (!response.ok) {
throw new SignageRequestError(await readFailure(response));
}
if (response.status === 204) {
return undefined as Result;
}
try {
return (await response.json()) as Result;
} catch {
throw new SignageRequestError({ ...GENERIC, status: response.status });
}
}
return {
get: (path) => request(path, undefined),
send: (method, path, body) =>
request(path, {
...(body === undefined
? {}
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
method,
}),
upload: (path, bytes, contentType) =>
request(path, { body: bytes, headers: { "content-type": contentType }, method: "POST" }),
};
}
+122
View File
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { type SignageApiFailure, SignageRequestError } from "./api.js";
/**
* One loading state, used everywhere, so every panel in the dashboard fails the same way.
*
* The states are the ones an operator can actually act on: still loading, loaded, or failed with a
* reason Signage itself gave. There is no fourth state for "loaded but empty" — a panel decides what
* empty means for its own data, because "no timelines yet" and "no displays yet" are different
* sentences.
*/
export type Loadable<Value> =
| { readonly status: "loading" }
| { readonly status: "ready"; readonly value: Value }
| { readonly status: "failed"; readonly failure: SignageApiFailure };
export interface Resource<Value> {
readonly state: Loadable<Value>;
/** Re-reads without flashing the panel back to a spinner: the previous value stays on screen
* until the new one arrives, which is what makes a five-second refresh bearable to look at. */
reload(): void;
}
export function useResource<Value>(
load: () => Promise<Value>,
dependencies: readonly unknown[],
): Resource<Value> {
const [state, setState] = useState<Loadable<Value>>({ status: "loading" });
const generation = useRef(0);
const loadRef = useRef(load);
loadRef.current = load;
const run = useCallback(() => {
generation.current += 1;
const token = generation.current;
void loadRef
.current()
.then((value) => {
// A response from a request the panel has already moved on from must never overwrite a
// newer one.
if (token === generation.current) {
setState({ status: "ready", value });
}
})
.catch((error: unknown) => {
if (token !== generation.current) {
return;
}
setState({
failure:
error instanceof SignageRequestError
? error.failure
: {
code: "SIGNAGE_REQUEST_FAILED",
message: "Signage could not be reached.",
status: 0,
},
status: "failed",
});
});
}, []);
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
// change, which is the whole reason this takes one.
useEffect(run, [run, ...dependencies]);
return { reload: run, state };
}
/** A mutation with a pending flag and a sanitized failure, so a button can disable itself and a form
* can say why a save was refused. */
export interface Action {
readonly pending: boolean;
readonly failure: SignageApiFailure | undefined;
run(work: () => Promise<unknown>): void;
clearFailure(): void;
}
export function useAction(onSettled?: () => void): Action {
const [pending, setPending] = useState(false);
const [failure, setFailure] = useState<SignageApiFailure | undefined>(undefined);
const settled = useRef(onSettled);
settled.current = onSettled;
const run = useCallback((work: () => Promise<unknown>) => {
setPending(true);
setFailure(undefined);
void work()
.then(() => {
setPending(false);
settled.current?.();
})
.catch((error: unknown) => {
setPending(false);
setFailure(
error instanceof SignageRequestError
? error.failure
: {
code: "SIGNAGE_REQUEST_FAILED",
message: "Signage could not be reached.",
status: 0,
},
);
});
}, []);
return { clearFailure: () => setFailure(undefined), failure, pending, run };
}
/** Re-renders on an interval, so a countdown or a "last seen" reads correctly without every panel
* inventing its own timer. Paused when the interval is zero. */
export function useTick(intervalMs: number): number {
const [tick, setTick] = useState(0);
useEffect(() => {
if (intervalMs <= 0) {
return;
}
const handle = setInterval(() => setTick((value) => value + 1), intervalMs);
return () => clearInterval(handle);
}, [intervalMs]);
return tick;
}
+25
View File
@@ -0,0 +1,25 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
import { createRoot } from "react-dom/client";
import { SignageDashboard } from "./SignageDashboard.js";
import ownStyles from "./styles.css";
/**
* Signage's compiled management-UI artifact. `pnpm build` bundles this file and everything it
* imports into the single browser artifact `entrypoints.ui` declares.
*
* `styles` is this artifact's complete stylesheet — the shared elements' rules plus Signage's own —
* compiled in as text and installed by the host into this artifact's isolated root at mount
* (ADR 0040). The design tokens both stylesheets reference are inherited from the host's own root
* rather than bundled (ADR 0039).
*/
export default defineModuleUi({
id: "signage",
version: "0.1.0",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) {
const root = createRoot(container);
root.render(<SignageDashboard context={context} />);
return () => root.unmount();
},
});
+288
View File
@@ -0,0 +1,288 @@
import {
Badge,
Button,
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, useTick } from "../hooks.js";
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
/**
* Channel groups, audience channels, and the display roster.
*
* A church starts with one group and one default channel. `in-person` and `online` are created here,
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
* understand and ignore (ADR 0004).
*/
export function DisplaysPanel({
api,
canManage,
}: {
readonly api: SignageApi;
readonly canManage: boolean;
}): ReactElement {
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
);
const viewers = useResource(
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
[],
);
const edit = useAction(() => {
groups.reload();
viewers.reload();
});
const [groupName, setGroupName] = useState("");
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
groupId: "",
key: "",
name: "",
});
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
useTick(5000);
const nowMs = Date.now();
const channels =
groups.state.status === "ready"
? groups.state.value.groups.flatMap((group) =>
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
)
: [];
return (
<div className="signage-columns">
<Panel
actions={
canManage ? (
<div className="signage-inline-form">
<Field label="New channel group">
{(control) => (
<TextInput
{...control}
onChange={(event) => setGroupName(event.target.value)}
placeholder="Nursery hallway"
value={groupName}
/>
)}
</Field>
<Button
disabled={edit.pending || groupName.trim().length === 0}
onClick={() =>
edit.run(async () => {
await api.send("POST", "channel-groups", { name: groupName.trim() });
setGroupName("");
})
}
variant="primary"
>
Create
</Button>
</div>
) : undefined
}
description="A group is one clock. An audience channel is one rendering of it."
title="Channel groups"
>
{renderLoadable(
groups.state,
(value) => (
<div className="signage-groups" data-testid="signage-groups">
{value.groups.map((group) => (
<div className="signage-group" key={group.id}>
<div className="signage-group__header">
<h4 className="signage-editor__subtitle">{group.name}</h4>
<Badge tone="info">{group.playback.transport}</Badge>
</div>
<ul className="ek-list">
{group.channels.map((channel) => (
<li className="ek-list__item signage-row" key={channel.id}>
<span className="signage-row__name">{channel.name}</span>
<Badge tone={channel.isDefault ? "success" : "neutral"}>
{channel.key}
</Badge>
{canManage && !channel.isDefault ? (
<Button
disabled={edit.pending}
onClick={() =>
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
}
variant="danger"
>
Remove
</Button>
) : undefined}
</li>
))}
</ul>
{canManage ? (
<div className="signage-inline-form" data-testid="signage-add-channel">
<Field hint="Lowercase, e.g. online" label="Audience key">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setChannelDraft({
groupId: group.id,
key: event.target.value,
name: channelDraft.name,
})
}
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
/>
)}
</Field>
<Field label="Channel name">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setChannelDraft({
groupId: group.id,
key: channelDraft.key,
name: event.target.value,
})
}
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
/>
)}
</Field>
<Button
disabled={
edit.pending ||
channelDraft.groupId !== group.id ||
channelDraft.key.trim() === "" ||
channelDraft.name.trim() === ""
}
onClick={() =>
edit.run(async () => {
await api.send("POST", `channel-groups/${group.id}/channels`, {
key: channelDraft.key.trim(),
name: channelDraft.name.trim(),
});
setChannelDraft({ groupId: "", key: "", name: "" });
})
}
>
Add audience channel
</Button>
</div>
) : undefined}
</div>
))}
</div>
),
{ label: "Loading channel groups…", onRetry: groups.reload },
)}
</Panel>
<Panel
description="Every screen Signage has seen, and what it last reported."
title="Displays"
>
{renderLoadable(
viewers.state,
(value) =>
value.viewers.length === 0 ? (
<EmptyState
description="Open a Signage display on a screen and it will register itself here."
title="No displays yet"
/>
) : (
<Table label="Display health">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
<TableCell header>State</TableCell>
<TableCell header>Channel</TableCell>
<TableCell header>Last seen</TableCell>
<TableCell header>Offline ready</TableCell>
<TableCell header>Most recent error</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.viewers.map((viewer) => (
<TableRow key={viewer.id}>
<TableCell>
{canManage ? (
<TextInput
aria-label={`Name for ${viewer.name}`}
defaultValue={viewer.name}
onBlur={(event) =>
event.target.value !== viewer.name &&
edit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
name: event.target.value,
}),
)
}
/>
) : (
viewer.name
)}
</TableCell>
<TableCell>
<OnlineBadge online={viewer.online} />
</TableCell>
<TableCell>
{canManage ? (
<Select
aria-label={`Channel for ${viewer.name}`}
onChange={(event) =>
edit.run(() =>
api.send("PATCH", `viewers/${viewer.id}`, {
channelId: event.target.value,
}),
)
}
value={viewer.channelId ?? ""}
>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{`${channel.groupName} · ${channel.name}`}
</option>
))}
</Select>
) : (
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
)}
</TableCell>
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
<TableCell>
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
</Badge>
</TableCell>
<TableCell>
{viewer.lastError === null ? (
"—"
) : (
<span title={viewer.lastError.detail ?? undefined}>
{viewer.lastError.code}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
{ label: "Loading displays…", onRetry: viewers.reload },
)}
<FailureNote failure={edit.failure} />
</Panel>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { Badge, Button, EmptyState, Field, Panel, TextInput } from "@ekklesia/ui";
import { type ChangeEvent, type ReactElement, useCallback, useMemo, useRef, useState } from "react";
import {
createUploadQueue,
type UploadFile,
type UploadItem,
type UploadQueue,
} from "../../browser/uploads.js";
import type { SignageApi } from "../api.js";
import { useAction, useResource } from "../hooks.js";
import type { ImageRecord, SettingsResponse } from "../types.js";
import { FailureNote, formatInstant, renderLoadable } from "./shared.js";
/**
* The native image library and its upload queue.
*
* The upload behaviour is the part worth reading: an operator drops twenty slides and gets twenty
* individually bounded requests, a few at a time. The page stays responsive because only a few are
* in flight; one bad file fails alone; and the operator learns about a file that is too large or of
* the wrong type before a byte is sent, while the server still checks both again.
*/
export function ImagesPanel({
api,
settings,
canManage,
}: {
readonly api: SignageApi;
readonly settings: SettingsResponse;
readonly canManage: boolean;
}): ReactElement {
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
const queueRef = useRef<UploadQueue | undefined>(undefined);
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
const rename = useAction(() => {
setRenaming(undefined);
images.reload();
});
const remove = useAction(() => images.reload());
const queue = useMemo(() => {
const created = createUploadQueue({
concurrency: settings.uploads.concurrency,
maxBytes: settings.uploads.maxBytes,
maxFiles: settings.uploads.batchMaxFiles,
onChange: (items) => setUploads(items),
upload: async (file) => {
// One image per request, with the label in the query string so the body is exactly the
// bytes. The server streams and counts them; nothing here buffers a second copy.
const query = new URLSearchParams({ displayName: file.name, filename: file.name });
await api.upload(`images?${query.toString()}`, file as unknown as Blob, file.type);
},
});
queueRef.current = created;
return created;
}, [
api,
settings.uploads.batchMaxFiles,
settings.uploads.concurrency,
settings.uploads.maxBytes,
]);
const onFiles = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []) as unknown as UploadFile[];
if (files.length === 0) {
return;
}
queue.enqueue(files);
void queue.drain().then(() => images.reload());
event.target.value = "";
},
[images, queue],
);
const finished = uploads.filter(
(item) => item.status !== "queued" && item.status !== "uploading",
);
return (
<Panel
actions={
canManage ? (
<label className="signage-upload-trigger">
<span className="ek-button ek-button--primary">Add images</span>
<input
accept="image/png,image/jpeg,image/webp"
className="signage-upload-input"
data-testid="signage-upload-input"
multiple
onChange={onFiles}
type="file"
/>
</label>
) : undefined
}
description={`PNG, JPEG, and WebP. Up to ${Math.round(settings.uploads.maxBytes / (1024 * 1024))} MiB each, ${settings.uploads.batchMaxFiles} at a time.`}
title="Images"
>
{uploads.length === 0 ? undefined : (
<div className="signage-uploads" data-testid="signage-uploads">
<ul className="ek-list">
{uploads.map((item) => (
<li className="ek-list__item signage-row" key={item.id}>
<span className="signage-row__name">{item.name}</span>
<Badge
tone={
item.status === "done"
? "success"
: item.status === "failed" || item.status === "rejected"
? "danger"
: "info"
}
>
{item.status}
</Badge>
{item.message === undefined ? undefined : (
<span className="signage-uploads__message">{item.message}</span>
)}
</li>
))}
</ul>
{finished.length === 0 ? undefined : (
<Button onClick={() => queue.clearFinished()}>Clear finished</Button>
)}
</div>
)}
{renderLoadable(
images.state,
(value) =>
value.images.length === 0 ? (
<EmptyState
description="Add a PNG, JPEG, or WebP and it will be available to every timeline."
title="No images yet"
/>
) : (
<ul className="signage-image-grid" data-testid="signage-image-grid">
{value.images.map((image) => {
const thumbnail = image.renditions.find(
(rendition) => rendition.kind === "thumbnail",
);
return (
<li className="signage-image" key={image.id}>
{thumbnail === undefined ? (
<div className="signage-image__missing">No preview</div>
) : (
<img
alt={image.displayName}
className="signage-image__thumbnail"
height={thumbnail.height}
src={thumbnail.url}
width={thumbnail.width}
/>
)}
<div className="signage-image__meta">
{renaming?.id === image.id ? (
<Field label="Name">
{(control) => (
<TextInput
{...control}
onChange={(event) =>
setRenaming({ id: image.id, value: event.target.value })
}
value={renaming.value}
/>
)}
</Field>
) : (
<p className="signage-image__name">{image.displayName}</p>
)}
<p className="signage-image__detail">
{`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}
</p>
{image.expiresAt === null ? undefined : (
<Badge className="signage-image__expiry" tone="warning">
{`Expires ${formatInstant(image.expiresAt, settings.timezone)}`}
</Badge>
)}
</div>
{canManage ? (
<div className="signage-image__actions">
{renaming?.id === image.id ? (
<>
<Button
disabled={rename.pending}
onClick={() =>
rename.run(() =>
api.send("PATCH", `images/${image.id}`, {
displayName: renaming.value,
}),
)
}
variant="primary"
>
Save
</Button>
<Button onClick={() => setRenaming(undefined)}>Cancel</Button>
</>
) : (
<Button
onClick={() => setRenaming({ id: image.id, value: image.displayName })}
>
Rename
</Button>
)}
<Button
disabled={remove.pending}
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
variant="danger"
>
Delete
</Button>
</div>
) : undefined}
</li>
);
})}
</ul>
),
{ label: "Loading images…", onRetry: images.reload },
)}
<FailureNote failure={rename.failure ?? remove.failure} />
</Panel>
);
}
+274
View File
@@ -0,0 +1,274 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js";
import type {
ChannelGroupView,
PlaybackDetailView,
SettingsResponse,
TimelineSummary,
} from "../types.js";
import { PreviewPanel } from "./PreviewPanel.js";
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
/**
* Live transport for one channel group.
*
* Every control here is the same operation the `signage.playback` capability exposes, over the same
* serialized service — a volunteer with a phone and an operator at this screen are pressing the same
* button, and neither can produce a state the other did not ask for.
*/
export function PlaybackPanel({
api,
context,
settings,
canControl,
groupId,
onGroupChange,
}: {
readonly api: SignageApi;
readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse;
readonly canControl: boolean;
readonly groupId: string | undefined;
readonly onGroupChange: (groupId: string) => void;
}): ReactElement {
const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[],
);
const timelines = useResource(
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
[],
);
const detail = useResource(
() =>
groupId === undefined
? Promise.resolve(undefined)
: api.get<PlaybackDetailView>(`playback/${groupId}`),
[groupId],
);
const command = useAction(() => {
detail.reload();
groups.reload();
});
const [activateId, setActivateId] = useState("");
const previewChannelId =
groups.state.status === "ready"
? (groups.state.value.groups
.find((group) => group.id === groupId)
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
: undefined;
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
useTick(1000);
function run(path: string, body?: unknown): void {
if (groupId === undefined) {
return;
}
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
}
return (
<div className="signage-columns">
<Panel
actions={renderLoadable(
groups.state,
(value) => (
<Field label="Channel group">
{(control) => (
<Select
{...control}
onChange={(event) => onGroupChange(event.target.value)}
value={groupId ?? ""}
>
{value.groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
)}
</Field>
),
{ label: "Loading groups…" },
)}
description="One clock for every channel in the group."
title="Playback"
>
{groupId === undefined ? (
<EmptyState title="No channel group selected" />
) : (
renderLoadable(
detail.state,
(value) =>
value === undefined ? (
<EmptyState title="No channel group selected" />
) : (
<div className="signage-playback" data-testid="signage-playback">
<div className="signage-playback__status">
<Badge
tone={
value.snapshot.transport === "playing"
? "success"
: value.snapshot.transport === "idle"
? "warning"
: "info"
}
>
{value.snapshot.transport}
</Badge>
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
{value.current?.stale === true ? (
<Badge tone="warning">Stale</Badge>
) : undefined}
{value.current?.withdrawn === true ? (
<Badge tone="warning">Withdrawn</Badge>
) : undefined}
{value.snapshot.pendingTimelineId === null ? undefined : (
<Badge tone="info">Activation queued</Badge>
)}
</div>
{value.current === null ? (
<EmptyState
description="Nothing is currently resolvable. A display shows its safe slate."
title="Nothing on screen"
/>
) : (
<dl className="signage-playback__current" data-testid="signage-current">
<dt>Provider</dt>
<dd>{value.current.providerId}</dd>
<dt>Item</dt>
<dd>{value.current.itemId}</dd>
<dt>Renderer</dt>
<dd>{value.current.rendererContributionId ?? "—"}</dd>
<dt>Duration</dt>
<dd>{formatDuration(value.current.durationMs)}</dd>
<dt>Remaining</dt>
<dd data-testid="signage-remaining">
{value.snapshot.remainingMs === null
? "—"
: formatDuration(value.snapshot.remainingMs)}
</dd>
<dt>Started</dt>
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
</dl>
)}
{canControl ? (
<div className="signage-playback__controls" data-testid="signage-controls">
<Button disabled={command.pending} onClick={() => run("previous")}>
Previous
</Button>
{value.snapshot.transport === "paused" ? (
<Button
disabled={command.pending}
onClick={() => run("resume")}
variant="primary"
>
Resume
</Button>
) : (
<Button disabled={command.pending} onClick={() => run("pause")}>
Pause
</Button>
)}
<Button disabled={command.pending} onClick={() => run("next")}>
Next
</Button>
<Button
disabled={command.pending}
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
variant={value.snapshot.blackout ? "primary" : "secondary"}
>
{value.snapshot.blackout ? "End blackout" : "Blackout"}
</Button>
{value.snapshot.staticAssetId === null ? undefined : (
<Button
disabled={command.pending}
onClick={() => run("static", { assetId: null })}
>
Release hold
</Button>
)}
<Button disabled={command.pending} onClick={() => run("reload")}>
Reload content
</Button>
</div>
) : undefined}
{canControl ? (
<div className="signage-playback__activate">
{renderLoadable(
timelines.state,
(loaded) => (
<>
<Field label="Activate timeline">
{(control) => (
<Select
{...control}
onChange={(event) => setActivateId(event.target.value)}
value={activateId}
>
<option value="">Choose a timeline…</option>
{loaded.timelines.map((timeline) => (
<option key={timeline.id} value={timeline.id}>
{timeline.name}
</option>
))}
</Select>
)}
</Field>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "now" })
}
variant="primary"
>
Now
</Button>
<Button
disabled={command.pending || activateId === ""}
onClick={() =>
run("activate", { timelineId: activateId, when: "next-boundary" })
}
>
At next slot
</Button>
</>
),
{ label: "Loading timelines…" },
)}
</div>
) : undefined}
{value.history.length === 0 ? undefined : (
<div className="signage-playback__history">
<h4 className="signage-editor__subtitle">Recently played</h4>
<ul className="ek-list">
{value.history.slice(0, 8).map((entry) => (
<li className="ek-list__item signage-row" key={entry.id}>
<span className="signage-row__name">{entry.itemId}</span>
<span className="signage-playback__history-meta">
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
</span>
</li>
))}
</ul>
</div>
)}
</div>
),
{ label: "Loading playback…", onRetry: detail.reload },
)
)}
<FailureNote failure={command.failure} />
</Panel>
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EmptyState, Panel } from "@ekklesia/ui";
import { type ReactElement, useEffect, useRef } from "react";
import { SignageSlate } from "../../browser/slate.js";
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
import type { SignageApi } from "../api.js";
/**
* The live preview.
*
* It mounts **the display controller itself** — the same scene stage, the same native-image
* presentation resolver, the same transition, the same renderer host — against the same channel
* state a wall reads. A preview that reimplemented any of that would drift from what the room
* actually sees, which makes it worse than no preview at all.
*
* It differs from a display in exactly two ways, both of them `telemetry: false`: it registers no
* viewer, so it never appears in the display roster, and it sends no heartbeat, so it never reports
* health for a screen that does not exist. It drives its own refresh instead of subscribing.
*/
export function PreviewPanel({
api,
channelId,
context,
refreshMs = 2000,
}: {
readonly api: SignageApi;
readonly channelId: string | undefined;
readonly context: ModuleUiHostContext;
readonly refreshMs?: number;
}): ReactElement {
const stageRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = stageRef.current;
if (!root || channelId === undefined) {
return;
}
let stopped = false;
const controller = createDisplayController({ context, root, telemetry: false });
let handle: ReturnType<typeof setTimeout> | undefined;
async function pump(): Promise<void> {
if (stopped) {
return;
}
try {
const state = await api.get<DisplayChannelStateView>(`channels/${channelId}/display-state`);
if (!stopped) {
await controller.apply(state);
}
} catch {
// A preview that cannot reach the server simply keeps its last frame, exactly as a wall
// does. Nothing about a failed poll belongs in the operator's face.
}
if (!stopped) {
handle = setTimeout(() => void pump(), refreshMs);
}
}
void controller.start().then(() => pump());
return () => {
stopped = true;
if (handle !== undefined) {
clearTimeout(handle);
}
void controller.stop();
};
}, [api, channelId, context, refreshMs]);
return (
<Panel
description="Exactly what a display on this channel is showing, rendered by the display's own code."
title="Live preview"
>
{channelId === undefined ? (
<EmptyState title="No channel to preview" />
) : (
<div className="signage-preview">
<div className="signage-preview__frame">
<div className="signage-stage" data-testid="signage-preview-stage" ref={stageRef}>
<div className="signage-slate-host">
<SignageSlate />
</div>
<div aria-hidden="true" className="signage-blackout" />
</div>
</div>
</div>
)}
</Panel>
);
}
+116
View File
@@ -0,0 +1,116 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import {
Badge,
EmptyState,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@ekklesia/ui";
import type { ReactElement } from "react";
import type { SignageApi } from "../api.js";
import { useResource } from "../hooks.js";
import type { ProviderHealth, SettingsResponse } from "../types.js";
import { formatInstant, renderLoadable } from "./shared.js";
const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
/**
* Provider health, and the one thing only a browser can tell an operator.
*
* A registration is a **running fact**: a provider that was uninstalled or disabled simply is not
* here, and there is no stored row claiming otherwise. Everything shown is either the provider's own
* declared identity or a count Signage kept — never a provider's exception, message, or payload.
*
* Renderer availability is checked here rather than on the server because the server genuinely
* cannot know it: whether the platform published a module's contribution is a fact the *host* holds,
* and `context.contributions` is where it is legible. A provider whose renderer was not published —
* because the module is missing, disabled, or outside Signage's compatible range — is reported as
* exactly that, which is the difference between "this is broken" and "this needs an update".
*/
export function ProvidersPanel({
api,
context,
settings,
}: {
readonly api: SignageApi;
readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse;
}): ReactElement {
const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[],
);
const published = new Set(
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
);
return (
<Panel
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
title="Content providers"
>
{renderLoadable(
providers.state,
(value) =>
value.providers.length === 0 ? (
<EmptyState
description="Signage works on its own with native images. Install a content module to add more."
title="No content providers registered"
/>
) : (
<Table label="Provider health">
<TableHead>
<TableRow>
<TableCell header>Provider</TableCell>
<TableCell header>Contract</TableCell>
<TableCell header>Renderer</TableCell>
<TableCell header>Health</TableCell>
<TableCell header>Last success</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.providers.map((provider) => {
const rendererPublished = published.has(provider.renderer.contributionId);
const isNative = provider.renderer.contributionId === "signage.native-image";
return (
<TableRow key={provider.providerId}>
<TableCell>
<span className="signage-provider__id">{provider.providerId}</span>
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
</TableCell>
<TableCell>{provider.contractVersion}</TableCell>
<TableCell>
{isNative ? (
<Badge tone="success">Signage renders this</Badge>
) : rendererPublished ? (
<Badge tone="success">{provider.renderer.contributionId}</Badge>
) : (
<Badge tone="warning">Renderer not published</Badge>
)}
</TableCell>
<TableCell>
{provider.consecutiveFailures === 0 ? (
<Badge tone="success">Healthy</Badge>
) : (
<Badge tone="danger">
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
</Badge>
)}
</TableCell>
<TableCell>
{formatInstant(provider.lastSuccessAt, settings.timezone)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
),
{ label: "Loading providers…", onRetry: providers.reload },
)}
</Panel>
);
}
+476
View File
@@ -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;
}
+91
View File
@@ -0,0 +1,91 @@
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui";
import type { ReactElement, ReactNode } from "react";
import type { SignageApiFailure } from "../api.js";
import type { Loadable } from "../hooks.js";
/**
* The one place a panel's loading, failure, and ready states are turned into elements.
*
* Every panel uses it, so a screen that cannot load looks the same wherever it happens — and the
* failure text is always Signage's own sanitized message, never a caught exception.
*/
export function renderLoadable<Value>(
state: Loadable<Value>,
render: (value: Value) => ReactNode,
options: { readonly label: string; readonly onRetry?: () => void },
): ReactNode {
if (state.status === "loading") {
return <LoadingState label={options.label} />;
}
if (state.status === "failed") {
return (
<ErrorState
action={
options.onRetry === undefined ? undefined : (
<button
className="ek-button ek-button--secondary"
onClick={options.onRetry}
type="button"
>
Try again
</button>
)
}
description={state.failure.message}
title="Signage could not load this"
/>
);
}
return render(state.value);
}
/** A refused save, shown where the operator was working. The server's own sanitized message and
* nothing else: no status line, no URL, no request detail. */
export function FailureNote({ failure }: { readonly failure: SignageApiFailure | undefined }) {
if (!failure) {
return undefined;
}
return (
<p className="signage-failure" role="alert">
{failure.message}
</p>
);
}
export function formatDuration(milliseconds: number): string {
const seconds = milliseconds / 1000;
return seconds % 1 === 0 ? `${seconds}s` : `${seconds.toFixed(1)}s`;
}
export function formatInstant(value: string | number | null, timezone: string): string {
if (value === null) {
return "—";
}
const date = typeof value === "number" ? new Date(value) : new Date(value);
if (Number.isNaN(date.getTime())) {
return "—";
}
// Always in the installation's own zone, and always labelled with it. A church reading "9:00"
// needs to know which nine o'clock, and a browser's local zone is not the answer.
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
timeZone: timezone,
}).format(date);
}
export function formatSince(atMs: number | null, nowMs: number): string {
if (atMs === null) {
return "never";
}
const seconds = Math.max(0, Math.round((nowMs - atMs) / 1000));
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.round(seconds / 60);
return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
}
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
}
+522
View File
@@ -0,0 +1,522 @@
/*
* The Signage management UI's own stylesheet.
*
* Compiled into the declared browser artifact as text and installed by the host into this artifact's
* isolated root at mount (ADR 0040), so these selectors reach nothing outside Signage and nothing
* outside Signage reaches in.
*
* Every value is either a registered Ekklesia token or a private `--signage-*` alias derived from
* one. Signage declares no `--ekklesia-*` property: those belong to the platform's global theme, and
* the build rejects a stylesheet that invents one (ADR 0039). Nothing here recolours a provider's
* output or an uploaded image — the Dashboard's theme is the Dashboard's, and a television's content
* belongs to whoever made it.
*/
/*
* The box model for the tree Signage owns.
*
* An isolated root inherits nothing from the page around it, and the platform's stylesheet declares
* no reset of its own — so under the initial `content-box` a `.ek-control`, which is `width: 100%`,
* is laid out wider than the field containing it by its own padding and border, and slides under
* whatever sits beside it. Setting the box model once here fixes that everywhere, rather than
* subtracting padding by hand in each panel.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
:host {
--signage-gap: var(--ekklesia-spacing-lg);
--signage-gap-tight: var(--ekklesia-spacing-sm);
--signage-panel-radius: var(--ekklesia-radius-lg);
--signage-thumbnail-width: 220px;
}
.signage-desktop {
display: none;
}
.signage-narrow-notice {
background: var(--ekklesia-background-surface);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-base);
border-radius: var(--signage-panel-radius);
color: var(--ekklesia-text-primary);
display: grid;
font-family: var(--ekklesia-typography-family-sans);
gap: var(--signage-gap-tight);
padding: var(--ekklesia-spacing-xl);
}
.signage-narrow-notice__title {
font-size: var(--ekklesia-typography-size-heading-3);
font-weight: var(--ekklesia-typography-weight-bold);
line-height: var(--ekklesia-typography-line-height-tight);
margin: 0;
}
.signage-narrow-notice__body {
color: var(--ekklesia-text-secondary);
line-height: var(--ekklesia-typography-line-height-normal);
margin: 0;
}
@media (min-width: 1024px) {
.signage-narrow-notice {
display: none;
}
.signage-desktop {
display: grid;
gap: var(--signage-gap);
}
}
.signage-columns {
display: grid;
gap: var(--signage-gap);
}
.signage-inline-form {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
/*
* A labelled control beside the button that submits it. The field takes the width its control asks
* for and is allowed to shrink below it, so a narrow header squeezes the control rather than sliding
* the button on top of it.
*/
.signage-inline-form > .ek-field {
min-width: 0;
position: relative;
}
.signage-inline-form .ek-control {
min-width: 0;
}
/*
* On one line, a field's hint hangs below the row rather than making that one field taller. The row
* is bottom-aligned so the controls share a line with the button, and a hint left in flow would push
* its own control above the others.
*/
.signage-inline-form > .ek-field > .ek-field__hint {
inset-block-start: 100%;
inset-inline-start: 0;
padding-block-start: var(--ekklesia-spacing-2xs);
position: absolute;
}
.signage-inline-form:has(.ek-field__hint) {
padding-block-end: var(--ekklesia-spacing-lg);
}
.signage-failure {
color: var(--ekklesia-text-danger);
font-size: var(--ekklesia-typography-size-sm);
margin: var(--signage-gap-tight) 0 0;
}
/*
* A list row that carries a name beside its metadata. The platform's list item is a plain block, so
* without this the two run together into one unreadable word — and the row's own children already
* assume a flex line.
*/
.signage-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-sm);
}
.signage-row__name {
flex: 1 1 auto;
}
/* ---------------------------------------------------------------------------------------------
* Images.
* ------------------------------------------------------------------------------------------- */
.signage-upload-trigger {
cursor: pointer;
display: inline-flex;
}
.signage-upload-input {
height: 1px;
opacity: 0;
position: absolute;
width: 1px;
}
.signage-uploads {
display: grid;
gap: var(--signage-gap-tight);
margin-bottom: var(--signage-gap);
}
.signage-uploads__message {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-image-grid {
display: grid;
gap: var(--signage-gap);
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.signage-image {
/* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather
than spread between the thumbnail and the name. */
align-content: start;
background: var(--ekklesia-background-canvas);
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--ekklesia-spacing-xs);
overflow: hidden;
padding: var(--ekklesia-spacing-sm);
}
/* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and
a tall image is letterboxed here rather than reflowing the card it sits in. */
.signage-image__thumbnail {
aspect-ratio: 16 / 9;
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-sm);
display: block;
height: auto;
max-width: 100%;
object-fit: contain;
width: 100%;
}
.signage-image__missing {
align-items: center;
aspect-ratio: 16 / 9;
background: var(--ekklesia-background-inset);
border-radius: var(--ekklesia-radius-sm);
color: var(--ekklesia-text-muted);
display: flex;
font-size: var(--ekklesia-typography-size-sm);
justify-content: center;
}
.signage-image__meta {
display: grid;
gap: var(--ekklesia-spacing-2xs);
}
/* A badge is sized by what it says, not by the column it lands in. */
.signage-image__expiry {
justify-self: start;
}
.signage-image__name {
font-weight: var(--ekklesia-typography-weight-medium);
margin: 0;
overflow-wrap: anywhere;
}
.signage-image__detail {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
margin: 0;
}
.signage-image__actions {
display: flex;
flex-wrap: wrap;
gap: var(--ekklesia-spacing-2xs);
}
/* ---------------------------------------------------------------------------------------------
* Timelines.
* ------------------------------------------------------------------------------------------- */
.signage-editor {
display: grid;
gap: var(--signage-gap);
}
.signage-editor__title {
font-size: var(--ekklesia-typography-size-heading-3);
font-weight: var(--ekklesia-typography-weight-bold);
margin: 0;
}
.signage-editor__subtitle {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
font-weight: var(--ekklesia-typography-weight-medium);
margin: 0;
text-transform: uppercase;
}
.signage-editor__add,
.signage-editor__sources,
.signage-editor__footer {
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
.signage-editor__sources {
display: grid;
gap: var(--ekklesia-spacing-xs);
}
.signage-editor__source {
align-items: center;
display: flex;
gap: var(--signage-gap-tight);
}
.signage-editor__order {
display: flex;
gap: var(--ekklesia-spacing-2xs);
}
/* ---------------------------------------------------------------------------------------------
* Playback.
* ------------------------------------------------------------------------------------------- */
.signage-playback {
display: grid;
gap: var(--signage-gap);
}
.signage-playback__status,
.signage-playback__controls,
.signage-playback__activate {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: var(--signage-gap-tight);
}
.signage-playback__current {
column-gap: var(--signage-gap);
display: grid;
grid-template-columns: max-content 1fr;
margin: 0;
row-gap: var(--ekklesia-spacing-2xs);
}
.signage-playback__current dt {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
.signage-playback__current dd {
font-variant-numeric: tabular-nums;
margin: 0;
}
.signage-playback__history-meta {
color: var(--ekklesia-text-secondary);
font-size: var(--ekklesia-typography-size-sm);
}
/* ---------------------------------------------------------------------------------------------
* Displays and providers.
* ------------------------------------------------------------------------------------------- */
.signage-groups {
display: grid;
gap: var(--signage-gap);
}
.signage-group {
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
border-radius: var(--ekklesia-radius-md);
display: grid;
gap: var(--signage-gap-tight);
padding: var(--ekklesia-spacing-md);
}
.signage-group__header {
align-items: center;
display: flex;
gap: var(--signage-gap-tight);
}
.signage-provider__id {
font-weight: var(--ekklesia-typography-weight-medium);
}
.signage-provider__version {
color: var(--ekklesia-text-muted);
font-size: var(--ekklesia-typography-size-sm);
/* Far enough that the version reads as a second word, not the tail of the identifier. */
margin-left: var(--ekklesia-spacing-sm);
}
/* ---------------------------------------------------------------------------------------------
* Live preview.
*
* The frame is a 16:9 box with a black ground, because that is what a television is. Inside it the
* display's own scene layers render exactly as they do on a wall — this panel supplies a size and
* nothing else.
* ------------------------------------------------------------------------------------------- */
.signage-preview {
display: grid;
gap: var(--signage-gap-tight);
}
.signage-preview__frame {
aspect-ratio: 16 / 9;
background: #000000;
border: var(--ekklesia-border-width) solid var(--ekklesia-border-strong);
border-radius: var(--ekklesia-radius-md);
overflow: hidden;
position: relative;
width: 100%;
}
.signage-preview .signage-stage {
height: 100%;
inset: 0;
min-height: 0;
position: absolute;
width: 100%;
}
.signage-preview .signage-layer {
height: 100%;
inset: 0;
opacity: 0;
position: absolute;
transition-duration: 0ms;
transition-property: opacity;
transition-timing-function: linear;
width: 100%;
}
.signage-preview .signage-layer__background {
background: #000000;
inset: 0;
position: absolute;
}
.signage-preview .signage-layer__background-image {
display: none;
height: 100%;
inset: 0;
object-fit: cover;
position: absolute;
transform: scale(1.08);
width: 100%;
}
.signage-preview .signage-layer__darken {
background: #000000;
inset: 0;
opacity: 0;
position: absolute;
}
.signage-preview .signage-layer__foreground {
height: 100%;
inset: 0;
object-fit: contain;
position: absolute;
width: 100%;
}
.signage-preview .signage-layer__renderer {
inset: 0;
position: absolute;
}
.signage-preview .signage-blackout {
background: #000000;
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 200ms linear;
z-index: 20;
}
.signage-preview [data-blackout="true"] > .signage-blackout {
opacity: 1;
}
.signage-preview .signage-slate-host {
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
z-index: 10;
}
.signage-preview [data-slate="true"] > .signage-slate-host {
opacity: 1;
}
.signage-preview .signage-slate {
align-items: stretch;
display: flex;
flex-direction: column;
height: 100%;
inset: 0;
position: absolute;
width: 100%;
}
.signage-preview .signage-slate__bars {
display: grid;
flex: 1 1 auto;
grid-template-columns: repeat(6, 1fr);
}
.signage-preview .signage-slate__bar {
display: block;
height: 100%;
}
.signage-preview .signage-slate__bar--one {
background: #6f6f6f;
}
.signage-preview .signage-slate__bar--two {
background: #6f6f2f;
}
.signage-preview .signage-slate__bar--three {
background: #2f6f6f;
}
.signage-preview .signage-slate__bar--four {
background: #2f6f2f;
}
.signage-preview .signage-slate__bar--five {
background: #6f2f6f;
}
.signage-preview .signage-slate__bar--six {
background: #6f2f2f;
}
.signage-preview .signage-slate__message {
background: #000000;
color: #ffffff;
flex: 0 0 auto;
font-size: 2vh;
letter-spacing: 0.08em;
margin: 0;
padding: 1.5vh 0;
text-align: center;
text-transform: uppercase;
}
+198
View File
@@ -0,0 +1,198 @@
/**
* The wire shapes Signage's dashboard reads.
*
* Signage owns both ends of every one of these, which is exactly why they are written down: the
* server's route and this file are one contract, and a drift between them is a bug an operator sees
* rather than a compiler does.
*/
export interface SettingsResponse {
readonly crossfadeMs: number;
readonly moduleVersion: string;
readonly nativeImageDurationMs: number;
readonly nativePresentation: {
readonly backgroundMode: string;
readonly backgroundColor: string;
readonly backgroundDarkened: boolean;
readonly blurPixels: number;
};
readonly rendererInputVersion: string;
readonly timezone: string;
readonly uploads: {
readonly batchMaxFiles: number;
readonly concurrency: number;
readonly maxBytes: number;
readonly maxPixels: number;
};
}
export interface ImageRendition {
readonly kind: string;
readonly url: string;
readonly expiresAt: string;
readonly width: number;
readonly height: number;
}
export interface ImageRecord {
readonly id: string;
readonly displayName: string;
readonly originalFilename: string;
readonly contentType: string;
readonly byteSize: number;
readonly width: number;
readonly height: number;
readonly status: string;
readonly expiresAt: string | null;
readonly createdAt: string;
readonly renditions: readonly ImageRendition[];
}
export interface TimelineSummary {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly eligibleFrom: string | null;
readonly eligibleUntil: string | null;
readonly revision: number;
readonly updatedAt: string;
}
export interface TimelineSource {
readonly id: string;
readonly label: string;
readonly providerId: string;
readonly sourceId: string;
}
export interface TimelineSlot {
readonly id: string;
readonly position: number;
readonly kind: "provider-source" | "pinned-item";
readonly sourceId: string | null;
readonly providerId: string | null;
readonly itemId: string | null;
readonly enabled: boolean;
readonly durationOverrideMs: number | null;
readonly eligibleFrom: string | null;
readonly eligibleUntil: string | null;
readonly presentation: {
readonly backgroundMode: string | null;
readonly backgroundColor: string | null;
readonly backgroundDarkened: boolean | null;
readonly blurPixels: number | null;
readonly foregroundShadow: boolean | null;
};
}
export interface TimelineDetail {
readonly timeline: TimelineSummary;
readonly sources: readonly TimelineSource[];
readonly slots: readonly TimelineSlot[];
}
export interface ProviderHealth {
readonly providerId: string;
readonly providerVersion: string;
readonly contractVersion: string;
readonly renderer: { readonly contributionId: string; readonly contributionVersion: string };
readonly defaultDurationMs: number;
readonly registeredAt: string;
readonly consecutiveFailures: number;
readonly lastFailureCode: string | null;
readonly lastFailureAt: string | null;
readonly lastSuccessAt: string | null;
}
export interface ProviderSourceDescriptor {
readonly id: string;
readonly label: string;
readonly description?: string;
}
export interface ProviderItemDescriptor {
readonly id: string;
readonly label: string;
readonly description?: string;
readonly availableUntil?: string | null;
}
export type TransportState = "idle" | "playing" | "paused" | "static";
export interface PlaybackSnapshotView {
readonly groupId: string;
readonly revision: number;
readonly serverNowMs: number;
readonly transport: TransportState;
readonly blackout: boolean;
readonly activeTimelineId: string | null;
readonly pendingTimelineId: string | null;
readonly scheduledTimelineId: string | null;
readonly scheduledActivationAtMs: number | null;
readonly currentSelectionId: string | null;
readonly currentSlotId: string | null;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
readonly remainingMs: number | null;
readonly selectionRevision: number | null;
readonly staticAssetId: string | null;
}
export interface ChannelView {
readonly id: string;
readonly key: string;
readonly name: string;
readonly isDefault: boolean;
}
export interface ChannelGroupView {
readonly id: string;
readonly name: string;
readonly revision: number;
readonly channels: readonly ChannelView[];
readonly playback: PlaybackSnapshotView;
}
export interface PlaybackDetailView {
readonly snapshot: PlaybackSnapshotView;
readonly current: {
readonly id: string;
readonly itemId: string;
readonly itemRevision: string;
readonly providerId: string;
readonly rendererContributionId: string | null;
readonly revision: number;
readonly stale: boolean;
readonly withdrawn: boolean;
readonly durationMs: number;
readonly startedAtMs: number | null;
readonly deadlineAtMs: number | null;
} | null;
readonly history: readonly {
readonly id: string;
readonly itemId: string;
readonly providerId: string;
readonly sequence: number;
readonly startedAtMs: number | null;
}[];
}
export interface ViewerHealthView {
readonly id: string;
readonly name: string;
readonly online: boolean;
readonly channelId: string | null;
readonly deviceId: string | null;
readonly lastSeenAtMs: number | null;
readonly reportedTimelineId: string | null;
readonly reportedSelectionId: string | null;
readonly reportedProviderId: string | null;
readonly reportedContributionId: string | null;
readonly offlineReady: boolean;
readonly cachedAssetCount: number;
readonly lastError: {
readonly code: string;
readonly detail: string | null;
readonly atMs: number;
} | null;
}