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
+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>
);
}