fix(ui): keep Signage state consistent across tabs, and confirm destructive actions
Activating a timeline from Playback left Timelines showing it as inactive, because each tab read its own stale copy of the channel groups; operators read that as activation not sticking. Tabs also mounted every panel afresh, so each switch flashed a spinner and collapsed the page before regrowing. Panels now share one stale-while-revalidate resource cache, and Playback follows the management realtime channel so Now showing tracks the wall. Deleting an image, a timeline, a slot, a source, or an audience happened on a single click; each is now a two-step in-place confirmation (DESIGN 21.3). The audience key was presented as a field to fill in. Audiences are now named in plain words, explained (images look the same for every audience), and the content key is derived from the name with an Advanced override. The roster labels signed-in browsers and lets an offline entry be removed after confirmation, so test tabs and retired screens no longer accumulate. The preview keeps nothing in the Dashboard origin's storage, and the narrow-screen notice no longer names the platform. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
67d89f1020
commit
7c17282616
+165
-168
@@ -1,9 +1,9 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
|
||||
import { type ReactElement, useMemo, useState } from "react";
|
||||
import { type ReactElement, useContext, useMemo, useState } from "react";
|
||||
import { DesktopOnly } from "../browser/desktop-gate.js";
|
||||
import { createSignageApi } from "./api.js";
|
||||
import { useResource } from "./hooks.js";
|
||||
import { createResourceCache, ResourceCacheContext, useResource } from "./hooks.js";
|
||||
import { DisplaysPanel } from "./panels/DisplaysPanel.js";
|
||||
import { ImagesPanel } from "./panels/ImagesPanel.js";
|
||||
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
|
||||
@@ -22,175 +22,172 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
||||
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
|
||||
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
|
||||
*/
|
||||
export function SignageDashboard({
|
||||
context,
|
||||
copyText,
|
||||
}: {
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly copyText: (value: string) => Promise<void>;
|
||||
export function SignageDashboard(props: {
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly copyText: (value: string) => Promise<void>;
|
||||
}): ReactElement {
|
||||
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
||||
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
||||
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
||||
// request it will certainly refuse is noise in a log rather than security.
|
||||
const mayView = context.permissions.has("signage.view");
|
||||
const settings = useResource(
|
||||
() =>
|
||||
mayView
|
||||
? api.get<SettingsResponse>("settings")
|
||||
: Promise.resolve(undefined),
|
||||
[mayView],
|
||||
);
|
||||
const groups = useResource(
|
||||
() =>
|
||||
mayView
|
||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||
: Promise.resolve(undefined),
|
||||
[mayView],
|
||||
);
|
||||
const [tab, setTab] = useState("playback");
|
||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||
// One cache for the life of this screen: everything the panels have loaded, shared between them.
|
||||
const [cache] = useState(createResourceCache);
|
||||
return (
|
||||
<ResourceCacheContext.Provider value={cache}>
|
||||
<SignageWorkspace {...props} />
|
||||
</ResourceCacheContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||
const mayManageTimelines = context.permissions.has(
|
||||
"signage.timelines.manage",
|
||||
);
|
||||
const mayControl = context.permissions.has("signage.playback.control");
|
||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||
function SignageWorkspace({
|
||||
context,
|
||||
copyText,
|
||||
}: {
|
||||
readonly context: ModuleUiHostContext;
|
||||
readonly copyText: (value: string) => Promise<void>;
|
||||
}): ReactElement {
|
||||
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
||||
const cache = useContext(ResourceCacheContext);
|
||||
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
||||
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
||||
// request it will certainly refuse is noise in a log rather than security.
|
||||
const mayView = context.permissions.has("signage.view");
|
||||
const settings = useResource(
|
||||
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
|
||||
[mayView],
|
||||
mayView ? "settings" : undefined,
|
||||
);
|
||||
const groups = useResource(
|
||||
() =>
|
||||
mayView
|
||||
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||
: Promise.resolve(undefined),
|
||||
[mayView],
|
||||
mayView ? "channel-groups" : undefined,
|
||||
);
|
||||
const [tab, setTab] = useState("playback");
|
||||
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||
|
||||
if (!mayView) {
|
||||
return (
|
||||
<EmptyState
|
||||
description="Ask an administrator for the signage.view permission."
|
||||
title="No access to Signage"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
|
||||
const mayControl = context.permissions.has("signage.playback.control");
|
||||
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||
|
||||
const selectedGroupId =
|
||||
groupId ??
|
||||
(groups.state.status === "ready"
|
||||
? groups.state.value?.groups[0]?.id
|
||||
: undefined);
|
||||
const selectedGroup =
|
||||
groups.state.status === "ready"
|
||||
? groups.state.value?.groups.find(
|
||||
(candidate) => candidate.id === selectedGroupId,
|
||||
)
|
||||
: undefined;
|
||||
if (!mayView) {
|
||||
return (
|
||||
<EmptyState
|
||||
description="Ask an administrator for the signage.view permission."
|
||||
title="No access to Signage"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<DesktopOnly>
|
||||
<PageHeader
|
||||
description="Plan visual content, operate playback, and keep every screen connected."
|
||||
eyebrow="Media"
|
||||
title="Signage"
|
||||
/>
|
||||
{renderLoadable(
|
||||
settings.state,
|
||||
(loaded) =>
|
||||
loaded === undefined ? undefined : (
|
||||
<div className="signage-workspace">
|
||||
<Tabs
|
||||
label="Signage sections"
|
||||
onSelect={setTab}
|
||||
selectedId={tab}
|
||||
tabs={[
|
||||
{
|
||||
content: (
|
||||
<PlaybackPanel
|
||||
api={api}
|
||||
canControl={mayControl}
|
||||
context={context}
|
||||
groupId={selectedGroupId}
|
||||
onGroupChange={setGroupId}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "playback",
|
||||
label: "Playback",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<TimelinesPanel
|
||||
{...(selectedGroup
|
||||
? {
|
||||
activeGroupName: selectedGroup.name,
|
||||
activeTimelineId:
|
||||
selectedGroup.playback.activeTimelineId,
|
||||
}
|
||||
: {})}
|
||||
api={api}
|
||||
canManage={mayManageTimelines}
|
||||
onActivate={
|
||||
mayControl && selectedGroupId !== undefined
|
||||
? async (timelineId) => {
|
||||
await api.send(
|
||||
"POST",
|
||||
`playback/${selectedGroupId}/activate`,
|
||||
{
|
||||
timelineId,
|
||||
when: "now",
|
||||
},
|
||||
);
|
||||
groups.reload();
|
||||
setTab("playback");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "timelines",
|
||||
label: "Timelines",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<ImagesPanel
|
||||
api={api}
|
||||
canManage={mayManageAssets}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "images",
|
||||
label: "Images",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<DisplaysPanel
|
||||
api={api}
|
||||
canManage={mayManageDisplays}
|
||||
copyText={copyText}
|
||||
displaySetupUrl={context.display?.url}
|
||||
onGroupsChanged={() => {
|
||||
setGroupId(undefined);
|
||||
groups.reload();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
id: "displays",
|
||||
label: "Displays",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<ProvidersPanel
|
||||
api={api}
|
||||
context={context}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "providers",
|
||||
label: "Providers",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
{ label: "Loading Signage…", onRetry: settings.reload },
|
||||
)}
|
||||
</DesktopOnly>
|
||||
</>
|
||||
);
|
||||
const selectedGroupId =
|
||||
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
|
||||
const selectedGroup =
|
||||
groups.state.status === "ready"
|
||||
? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<DesktopOnly>
|
||||
<PageHeader
|
||||
description="Plan visual content, operate playback, and keep every screen connected."
|
||||
eyebrow="Media"
|
||||
title="Signage"
|
||||
/>
|
||||
{renderLoadable(
|
||||
settings.state,
|
||||
(loaded) =>
|
||||
loaded === undefined ? undefined : (
|
||||
<div className="signage-workspace">
|
||||
<Tabs
|
||||
label="Signage sections"
|
||||
onSelect={setTab}
|
||||
selectedId={tab}
|
||||
tabs={[
|
||||
{
|
||||
content: (
|
||||
<PlaybackPanel
|
||||
api={api}
|
||||
canControl={mayControl}
|
||||
context={context}
|
||||
groupId={selectedGroupId}
|
||||
onGroupChange={setGroupId}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "playback",
|
||||
label: "Playback",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<TimelinesPanel
|
||||
{...(selectedGroup
|
||||
? {
|
||||
activeGroupName: selectedGroup.name,
|
||||
activeTimelineId: selectedGroup.playback.activeTimelineId,
|
||||
}
|
||||
: {})}
|
||||
api={api}
|
||||
canManage={mayManageTimelines}
|
||||
onActivate={
|
||||
mayControl && selectedGroupId !== undefined
|
||||
? async (timelineId) => {
|
||||
await api.send("POST", `playback/${selectedGroupId}/activate`, {
|
||||
timelineId,
|
||||
when: "now",
|
||||
});
|
||||
// Playback opens on what is now true, not on the
|
||||
// timeline it was showing before.
|
||||
cache?.write(
|
||||
`playback/${selectedGroupId}`,
|
||||
await api.get(`playback/${selectedGroupId}`),
|
||||
);
|
||||
groups.reload();
|
||||
setTab("playback");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
settings={loaded}
|
||||
/>
|
||||
),
|
||||
id: "timelines",
|
||||
label: "Timelines",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
||||
),
|
||||
id: "images",
|
||||
label: "Images",
|
||||
},
|
||||
{
|
||||
content: (
|
||||
<DisplaysPanel
|
||||
api={api}
|
||||
canManage={mayManageDisplays}
|
||||
copyText={copyText}
|
||||
displaySetupUrl={context.display?.url}
|
||||
onGroupsChanged={() => {
|
||||
setGroupId(undefined);
|
||||
groups.reload();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
id: "displays",
|
||||
label: "Displays",
|
||||
},
|
||||
{
|
||||
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
|
||||
id: "providers",
|
||||
label: "Providers",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
{ label: "Loading Signage…", onRetry: settings.reload },
|
||||
)}
|
||||
</DesktopOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user