import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui"; import { type ReactElement, useMemo, useState } from "react"; import { DesktopOnly } from "../browser/desktop-gate.js"; import { createSignageApi } from "./api.js"; import { useResource } from "./hooks.js"; import { DisplaysPanel } from "./panels/DisplaysPanel.js"; import { ImagesPanel } from "./panels/ImagesPanel.js"; import { PlaybackPanel } from "./panels/PlaybackPanel.js"; import { ProvidersPanel } from "./panels/ProvidersPanel.js"; import { renderLoadable } from "./panels/shared.js"; import { TimelinesPanel } from "./panels/TimelinesPanel.js"; import type { ChannelGroupView, SettingsResponse } from "./types.js"; /** * The Signage management screen. * * Host-neutral by construction: every value comes from the host context, and nothing here hardcodes * a URL, an origin, or a global. Presentation is entirely the platform's shared elements plus * Signage's own stylesheet, so this looks like the rest of Ekklesia without copying a colour. * * 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; }): 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("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(undefined); 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"); if (!mayView) { return ( ); } 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 ( <> {renderLoadable( settings.state, (loaded) => loaded === undefined ? undefined : (
), id: "playback", label: "Playback", }, { content: ( { await api.send( "POST", `playback/${selectedGroupId}/activate`, { timelineId, when: "now", }, ); groups.reload(); setTab("playback"); } : undefined } settings={loaded} /> ), id: "timelines", label: "Timelines", }, { content: ( ), id: "images", label: "Images", }, { content: ( { setGroupId(undefined); groups.reload(); }} /> ), id: "displays", label: "Displays", }, { content: ( ), id: "providers", label: "Providers", }, ]} />
), { label: "Loading Signage…", onRetry: settings.reload }, )}
); }