fix: complete display and channel group management

This commit is contained in:
ekklesia
2026-09-29 18:15:26 -05:00
parent 5760059ee3
commit aa8cb1c96f
10 changed files with 243 additions and 19 deletions
+10 -1
View File
@@ -91,11 +91,20 @@ export function registerPlaybackRoutes(
const groupId = requireUuidParam(context, "groupId");
const store = createSignageStore(context.services.database);
const groups = await store.listChannelGroups();
const group = groups.find((candidate) => candidate.group.id === groupId);
if (!group) {
throw new SignageError("SIGNAGE_NOT_FOUND");
}
if (groups.length <= 1) {
// An installation always has somewhere for a viewer to land.
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
}
await store.deleteChannelGroup(groupId);
const fallbackGroup = groups.find((candidate) => candidate.group.id !== groupId);
const fallbackChannel = fallbackGroup?.channels.find((channel) => channel.isDefault);
if (!fallbackChannel) {
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-fallback-channel" });
}
await store.deleteChannelGroup(groupId, fallbackChannel.id);
return new Response(null, { status: 204 });
});
},
+16 -2
View File
@@ -7,6 +7,7 @@ import {
playbackStates,
selections,
selectionVariants,
viewers,
} from "../../schema/tables.js";
import {
type NewStoredSelection,
@@ -176,8 +177,21 @@ export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackSt
});
},
async deleteChannelGroup(groupId: string): Promise<void> {
await database.delete(channelGroups).where(eq(channelGroups.id, groupId));
async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void> {
await database.transaction(async (transaction) => {
const removedChannels = await transaction
.select({ id: channels.id })
.from(channels)
.where(eq(channels.groupId, groupId));
const removedChannelIds = removedChannels.map((channel) => channel.id);
if (removedChannelIds.length > 0) {
await transaction
.update(viewers)
.set({ channelId: fallbackChannelId, updatedAt: new Date() })
.where(inArray(viewers.channelId, removedChannelIds));
}
await transaction.delete(channelGroups).where(eq(channelGroups.id, groupId));
});
},
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
+3 -1
View File
@@ -351,7 +351,9 @@ export interface SignagePlaybackStore {
readonly channelId: string;
readonly channelName: string;
}): Promise<void>;
deleteChannelGroup(groupId: string): Promise<void>;
/** Deletes a group and moves every assigned viewer to the supplied fallback channel atomically,
* so removing a group can never leave a display unassigned. */
deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise<void>;
addChannel(channel: StoredChannel): Promise<void>;
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
removeChannel(channelId: string): Promise<void>;
+10 -1
View File
@@ -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",
},
+77 -13
View File
@@ -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>
);
+34
View File
@@ -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);
}