fix: complete display and channel group management
This commit is contained in:
@@ -131,7 +131,16 @@ export function SignageDashboard({
|
||||
label: "Images",
|
||||
},
|
||||
{
|
||||
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
|
||||
content: (
|
||||
<DisplaysPanel
|
||||
api={api}
|
||||
canManage={mayManageDisplays}
|
||||
onGroupsChanged={() => {
|
||||
setGroupId(undefined);
|
||||
groups.reload();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
id: "displays",
|
||||
label: "Displays",
|
||||
},
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useAction, useResource, useTick } from "../hooks.js";
|
||||
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
||||
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
|
||||
|
||||
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
|
||||
/**
|
||||
* Channel groups, audience channels, and the display roster.
|
||||
*
|
||||
@@ -28,9 +30,11 @@ import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.
|
||||
export function DisplaysPanel({
|
||||
api,
|
||||
canManage,
|
||||
onGroupsChanged,
|
||||
}: {
|
||||
readonly api: SignageApi;
|
||||
readonly canManage: boolean;
|
||||
readonly onGroupsChanged?: () => void;
|
||||
}): ReactElement {
|
||||
const groups = useResource(
|
||||
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||
@@ -43,8 +47,11 @@ export function DisplaysPanel({
|
||||
const edit = useAction(() => {
|
||||
groups.reload();
|
||||
viewers.reload();
|
||||
onGroupsChanged?.();
|
||||
});
|
||||
const viewerEdit = useAction(viewers.reload);
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [confirmGroupId, setConfirmGroupId] = useState<string | undefined>(undefined);
|
||||
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
|
||||
groupId: "",
|
||||
key: "",
|
||||
@@ -105,7 +112,41 @@ export function DisplaysPanel({
|
||||
<div className="signage-group__header">
|
||||
<h4 className="signage-editor__subtitle">{group.name}</h4>
|
||||
<Badge tone="info">{group.playback.transport}</Badge>
|
||||
{canManage && value.groups.length > 1 ? (
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() => setConfirmGroupId(group.id)}
|
||||
variant="danger"
|
||||
>
|
||||
Delete group
|
||||
</Button>
|
||||
) : canManage ? (
|
||||
<Badge tone="neutral">At least one group required</Badge>
|
||||
) : undefined}
|
||||
</div>
|
||||
{confirmGroupId === group.id ? (
|
||||
<div className="signage-confirm" role="alert">
|
||||
<p>
|
||||
Delete {group.name}? Its playback history will be removed. Displays assigned
|
||||
here will move to the default channel of another group.
|
||||
</p>
|
||||
<Button disabled={edit.pending} onClick={() => setConfirmGroupId(undefined)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={edit.pending}
|
||||
onClick={() =>
|
||||
edit.run(async () => {
|
||||
await api.send("DELETE", `channel-groups/${group.id}`);
|
||||
setConfirmGroupId(undefined);
|
||||
})
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
Confirm delete
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
<ul className="ek-list">
|
||||
{group.channels.map((channel) => (
|
||||
<li className="ek-list__item signage-row" key={channel.id}>
|
||||
@@ -129,32 +170,43 @@ export function DisplaysPanel({
|
||||
</ul>
|
||||
{canManage ? (
|
||||
<div className="signage-inline-form" data-testid="signage-add-channel">
|
||||
<Field hint="Lowercase, e.g. online" label="Audience key">
|
||||
<Field
|
||||
hint="Stable lowercase ID sent to content providers for audience-specific variants, e.g. online."
|
||||
label="Provider audience key"
|
||||
>
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
maxLength={64}
|
||||
onChange={(event) =>
|
||||
setChannelDraft({
|
||||
setChannelDraft((current) => ({
|
||||
groupId: group.id,
|
||||
key: event.target.value,
|
||||
name: channelDraft.name,
|
||||
})
|
||||
name: current.groupId === group.id ? current.name : "",
|
||||
}))
|
||||
}
|
||||
pattern="[a-z][a-z0-9]*(?:-[a-z0-9]+)*"
|
||||
placeholder="online"
|
||||
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Channel name">
|
||||
<Field
|
||||
hint="Friendly label shown to administrators and used when assigning displays."
|
||||
label="Dashboard channel name"
|
||||
>
|
||||
{(control) => (
|
||||
<TextInput
|
||||
{...control}
|
||||
maxLength={120}
|
||||
onChange={(event) =>
|
||||
setChannelDraft({
|
||||
setChannelDraft((current) => ({
|
||||
groupId: group.id,
|
||||
key: channelDraft.key,
|
||||
key: current.groupId === group.id ? current.key : "",
|
||||
name: event.target.value,
|
||||
})
|
||||
}))
|
||||
}
|
||||
placeholder="Online audience"
|
||||
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
||||
/>
|
||||
)}
|
||||
@@ -163,7 +215,8 @@ export function DisplaysPanel({
|
||||
disabled={
|
||||
edit.pending ||
|
||||
channelDraft.groupId !== group.id ||
|
||||
channelDraft.key.trim() === "" ||
|
||||
!AUDIENCE_KEY.test(channelDraft.key.trim()) ||
|
||||
channelDraft.key.trim() === "default" ||
|
||||
channelDraft.name.trim() === ""
|
||||
}
|
||||
onClick={() =>
|
||||
@@ -189,15 +242,25 @@ export function DisplaysPanel({
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
description="Every screen Signage has seen, and what it last reported."
|
||||
description="Pair a screen through Ekklesia, then name it and choose which audience channel it shows."
|
||||
title="Displays"
|
||||
>
|
||||
{canManage ? (
|
||||
<div className="signage-registration-guide">
|
||||
<h4 className="signage-editor__subtitle">Register a display</h4>
|
||||
<ol>
|
||||
<li>Open the Ekklesia path /display/signage on the screen.</li>
|
||||
<li>Enter its six-digit code under Display devices in the Dashboard sidebar.</li>
|
||||
<li>When it appears below, give it a clear name and assign its channel.</li>
|
||||
</ol>
|
||||
</div>
|
||||
) : undefined}
|
||||
{renderLoadable(
|
||||
viewers.state,
|
||||
(value) =>
|
||||
value.viewers.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Open a Signage display on a screen and it will register itself here."
|
||||
description="Open /display/signage on the screen, approve its code under Display devices, and it will appear here automatically."
|
||||
title="No displays yet"
|
||||
/>
|
||||
) : (
|
||||
@@ -222,7 +285,7 @@ export function DisplaysPanel({
|
||||
defaultValue={viewer.name}
|
||||
onBlur={(event) =>
|
||||
event.target.value !== viewer.name &&
|
||||
edit.run(() =>
|
||||
viewerEdit.run(() =>
|
||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||
name: event.target.value,
|
||||
}),
|
||||
@@ -241,7 +304,7 @@ export function DisplaysPanel({
|
||||
<Select
|
||||
aria-label={`Channel for ${viewer.name}`}
|
||||
onChange={(event) =>
|
||||
edit.run(() =>
|
||||
viewerEdit.run(() =>
|
||||
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||
channelId: event.target.value,
|
||||
}),
|
||||
@@ -282,6 +345,7 @@ export function DisplaysPanel({
|
||||
{ label: "Loading displays…", onRetry: viewers.reload },
|
||||
)}
|
||||
<FailureNote failure={edit.failure} />
|
||||
<FailureNote failure={viewerEdit.failure} />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -349,6 +349,40 @@
|
||||
gap: var(--signage-gap-tight);
|
||||
}
|
||||
|
||||
.signage-group__header .ek-button:first-of-type {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.signage-confirm {
|
||||
align-items: center;
|
||||
background: var(--ekklesia-status-danger-background);
|
||||
border-radius: var(--ekklesia-radius-md);
|
||||
color: var(--ekklesia-text-danger);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--signage-gap-tight);
|
||||
padding: var(--ekklesia-spacing-sm);
|
||||
}
|
||||
|
||||
.signage-confirm p {
|
||||
flex: 1 1 30rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signage-registration-guide {
|
||||
background: var(--ekklesia-background-inset);
|
||||
border-radius: var(--ekklesia-radius-md);
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-xs);
|
||||
margin-block-end: var(--signage-gap);
|
||||
padding: var(--ekklesia-spacing-md);
|
||||
}
|
||||
|
||||
.signage-registration-guide ol {
|
||||
margin: 0;
|
||||
padding-inline-start: var(--ekklesia-spacing-xl);
|
||||
}
|
||||
|
||||
.signage-provider__id {
|
||||
font-weight: var(--ekklesia-typography-weight-medium);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user