feat: initial Signage 0.1.0
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user