diff --git a/src/server/routes/playback.ts b/src/server/routes/playback.ts index 5fcd252..e90a81a 100644 --- a/src/server/routes/playback.ts +++ b/src/server/routes/playback.ts @@ -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 }); }); }, diff --git a/src/server/store/drizzle-playback.ts b/src/server/store/drizzle-playback.ts index 7995e31..d00ccc1 100644 --- a/src/server/store/drizzle-playback.ts +++ b/src/server/store/drizzle-playback.ts @@ -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 { - await database.delete(channelGroups).where(eq(channelGroups.id, groupId)); + async deleteChannelGroup(groupId: string, fallbackChannelId: string): Promise { + 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 { diff --git a/src/server/store/types.ts b/src/server/store/types.ts index abfd423..c80079b 100644 --- a/src/server/store/types.ts +++ b/src/server/store/types.ts @@ -351,7 +351,9 @@ export interface SignagePlaybackStore { readonly channelId: string; readonly channelName: string; }): Promise; - deleteChannelGroup(groupId: string): Promise; + /** 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; addChannel(channel: StoredChannel): Promise; /** Refuses to remove a group's default channel: something must remain for a viewer to join. */ removeChannel(channelId: string): Promise; diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx index 0267b86..8319e80 100644 --- a/src/ui/SignageDashboard.tsx +++ b/src/ui/SignageDashboard.tsx @@ -131,7 +131,16 @@ export function SignageDashboard({ label: "Images", }, { - content: , + content: ( + { + setGroupId(undefined); + groups.reload(); + }} + /> + ), id: "displays", label: "Displays", }, diff --git a/src/ui/panels/DisplaysPanel.tsx b/src/ui/panels/DisplaysPanel.tsx index 241d77a..8facd36 100644 --- a/src/ui/panels/DisplaysPanel.tsx +++ b/src/ui/panels/DisplaysPanel.tsx @@ -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(undefined); const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ groupId: "", key: "", @@ -105,7 +112,41 @@ export function DisplaysPanel({

{group.name}

{group.playback.transport} + {canManage && value.groups.length > 1 ? ( + + ) : canManage ? ( + At least one group required + ) : undefined}
+ {confirmGroupId === group.id ? ( +
+

+ Delete {group.name}? Its playback history will be removed. Displays assigned + here will move to the default channel of another group. +

+ + +
+ ) : undefined}
    {group.channels.map((channel) => (
  • @@ -129,32 +170,43 @@ export function DisplaysPanel({
{canManage ? (
- + {(control) => ( - 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 : ""} /> )} - + {(control) => ( - 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({ + {canManage ? ( +
+

Register a display

+
    +
  1. Open the Ekklesia path /display/signage on the screen.
  2. +
  3. Enter its six-digit code under Display devices in the Dashboard sidebar.
  4. +
  5. When it appears below, give it a clear name and assign its channel.
  6. +
+
+ ) : undefined} {renderLoadable( viewers.state, (value) => value.viewers.length === 0 ? ( ) : ( @@ -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({