diff --git a/src/ui/SignageDashboard.tsx b/src/ui/SignageDashboard.tsx index 8319e80..f1ff740 100644 --- a/src/ui/SignageDashboard.tsx +++ b/src/ui/SignageDashboard.tsx @@ -24,8 +24,12 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js"; */ export function SignageDashboard({ context, + copyText, + displaySetupUrl, }: { readonly context: ModuleUiHostContext; + readonly copyText: (value: string) => Promise; + readonly displaySetupUrl: string; }): 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. @@ -135,6 +139,8 @@ export function SignageDashboard({ { setGroupId(undefined); groups.reload(); diff --git a/src/ui/index.tsx b/src/ui/index.tsx index eb3fb7d..ec342ce 100644 --- a/src/ui/index.tsx +++ b/src/ui/index.tsx @@ -19,7 +19,19 @@ export default defineModuleUi({ styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, mount(container, context) { const root = createRoot(container); - root.render(); + const displaySetupUrl = new URL("/display/signage", globalThis.location.origin).href; + root.render( + { + if (globalThis.navigator.clipboard === undefined) { + throw new Error("Clipboard access is not available in this browser."); + } + await globalThis.navigator.clipboard.writeText(value); + }} + displaySetupUrl={displaySetupUrl} + />, + ); return () => root.unmount(); }, }); diff --git a/src/ui/panels/DisplaysPanel.tsx b/src/ui/panels/DisplaysPanel.tsx index 8facd36..4ff4a9c 100644 --- a/src/ui/panels/DisplaysPanel.tsx +++ b/src/ui/panels/DisplaysPanel.tsx @@ -1,17 +1,4 @@ -import { - Badge, - Button, - EmptyState, - Field, - Panel, - Select, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextInput, -} from "@ekklesia/ui"; +import { Badge, Button, Field, Panel, Select, TextInput } from "@ekklesia/ui"; import { type ReactElement, useState } from "react"; import type { SignageApi } from "../api.js"; import { useAction, useResource, useTick } from "../hooks.js"; @@ -20,30 +7,93 @@ import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared. const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +function DisplayRow({ + canManage, + groups, + nowMs, + onChannelChange, + onNameChange, + viewer, +}: { + readonly canManage: boolean; + readonly groups: readonly ChannelGroupView[]; + readonly nowMs: number; + readonly onChannelChange: (channelId: string) => void; + readonly onNameChange: (name: string) => void; + readonly viewer: ViewerHealthView; +}): ReactElement { + return ( +
+
+ {canManage ? ( + event.target.value !== viewer.name && onNameChange(event.target.value)} + /> + ) : ( + {viewer.name} + )} + +
+
+ {canManage ? ( + + {(control) => ( + + )} + + ) : undefined} +
+
+ {`Seen ${formatSince(viewer.lastSeenAtMs, nowMs)}`} + + {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not offline ready"} + + {viewer.lastError === null ? undefined : ( + + {viewer.lastError.code} + + )} +
+
+ ); +} + /** - * Channel groups, audience channels, and the display roster. - * - * A church starts with one group and one default channel. `in-person` and `online` are created here, - * when someone actually needs them — not shipped as rows nobody asked for and everybody has to - * understand and ignore (ADR 0004). + * Channel groups, audience channels, and displays, presented in the same hierarchy they use at run + * time: one group clock, one audience variant per channel, then the screens assigned to it. */ export function DisplaysPanel({ api, canManage, + copyText, + displaySetupUrl, onGroupsChanged, }: { readonly api: SignageApi; readonly canManage: boolean; + readonly copyText: (value: string) => Promise; + readonly displaySetupUrl: string; readonly onGroupsChanged?: () => void; }): ReactElement { - const groups = useResource( - () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), - [], - ); - const viewers = useResource( - () => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), - [], - ); + const groups = useResource(() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), []); + const viewers = useResource(() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"), []); const edit = useAction(() => { groups.reload(); viewers.reload(); @@ -51,30 +101,87 @@ export function DisplaysPanel({ }); const viewerEdit = useAction(viewers.reload); const [groupName, setGroupName] = useState(""); + const [copyStatus, setCopyStatus] = useState<"copied" | "failed" | "idle">("idle"); const [confirmGroupId, setConfirmGroupId] = useState(undefined); - const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({ + const [channelDraft, setChannelDraft] = useState<{ + groupId: string; + key: string; + name: string; + }>({ groupId: "", key: "", name: "", }); - // "Last seen" is a moving number: without a tick it would freeze at whatever it said when the - // roster loaded, and an operator would read a screen that went dark ten minutes ago as current. useTick(5000); const nowMs = Date.now(); + const readyViewers = viewers.state.status === "ready" ? viewers.state.value.viewers : []; - const channels = - groups.state.status === "ready" - ? groups.state.value.groups.flatMap((group) => - group.channels.map((channel) => ({ ...channel, groupName: group.name })), - ) - : []; + const updateViewer = (viewerId: string, changes: Record): void => { + viewerEdit.run(() => api.send("PATCH", `viewers/${viewerId}`, changes)); + }; return (
+ + + Open display setup + + +
+ } + description="Connect a screen in three short steps." + title="Pair a display" + > +
    +
  1. + 1 + Open display setup on the screen that will show signage. +
  2. +
  3. + 2 + Enter its six-digit code under Display devices in the Dashboard sidebar. +
  4. +
  5. + 3 + Name the display and choose its audience channel below. +
  6. +
+ + {copyStatus === "copied" + ? "Display setup URL copied." + : copyStatus === "failed" + ? "Could not copy the URL. Copy it from the newly opened setup page." + : ""} + + {viewers.state.status === "ready" && viewers.state.value.viewers.length === 0 ? ( +
+ No displays paired yet + A newly approved screen appears below in its assigned channel. +
+ ) : undefined} + + +
{(control) => ( edit.run(async () => { - await api.send("POST", "channel-groups", { name: groupName.trim() }); + await api.send("POST", "channel-groups", { + name: groupName.trim(), + }); setGroupName(""); }) } variant="primary" > - Create + Create group
) : undefined } - description="A group is one clock. An audience channel is one rendering of it." - title="Channel groups" + description="A channel group shares one playback clock. Each audience channel can receive a different content variant." + title="Channel groups and displays" > {renderLoadable( groups.state, - (value) => ( -
- {value.groups.map((group) => ( -
-
-

{group.name}

- {group.playback.transport} - {canManage && value.groups.length > 1 ? ( - - ) : canManage ? ( - At least one group required + (value) => { + const knownChannelIds = new Set( + value.groups.flatMap((group) => group.channels.map((channel) => channel.id)), + ); + const unassigned = readyViewers.filter( + (viewer) => viewer.channelId === null || !knownChannelIds.has(viewer.channelId), + ); + return ( +
+ {value.groups.map((group) => ( +
+
+
+ Channel group +

{group.name}

+
+ {group.playback.transport} + {canManage && value.groups.length > 1 ? ( + + ) : canManage ? ( + Required group + ) : 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} -
- {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) => ( -
  • - {channel.name} - - {channel.key} - - {canManage && !channel.isDefault ? ( - + ) : undefined} + +
    + Displays + {assigned.length === 0 ? ( +

    No displays assigned.

    + ) : ( + assigned.map((viewer) => ( + updateViewer(viewer.id, { channelId })} + onNameChange={(name) => updateViewer(viewer.id, { name })} + viewer={viewer} + /> + )) + )} +
    + + ); + })} +
+ + {canManage ? ( +
+
+
Add an audience channel
+ The default channel always remains available. +
+ + {(control) => ( + + setChannelDraft((current) => ({ + groupId: group.id, + key: current.groupId === group.id ? current.key : "", + name: event.target.value, + })) + } + placeholder="Online audience" + value={channelDraft.groupId === group.id ? channelDraft.name : ""} + /> + )} + + - Remove + {(control) => ( + + setChannelDraft((current) => ({ + groupId: group.id, + key: event.target.value, + 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 : ""} + /> + )} + + - ) : undefined} - +
+ ) : undefined} + + + ))} + + {unassigned.length === 0 ? undefined : ( +
+ Needs assignment + {unassigned.map((viewer) => ( + updateViewer(viewer.id, { channelId })} + onNameChange={(name) => updateViewer(viewer.id, { name })} + viewer={viewer} + /> ))} - - {canManage ? ( -
- - {(control) => ( - - setChannelDraft((current) => ({ - groupId: group.id, - key: event.target.value, - 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((current) => ({ - groupId: group.id, - key: current.groupId === group.id ? current.key : "", - name: event.target.value, - })) - } - placeholder="Online audience" - value={channelDraft.groupId === group.id ? channelDraft.name : ""} - /> - )} - - -
- ) : undefined} -
- ))} -
- ), + + )} + + ); + }, { label: "Loading channel groups…", onRetry: groups.reload }, )} -
- - - {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 ? ( - - ) : ( - - - - Name - State - Channel - Last seen - Offline ready - Most recent error - - - - {value.viewers.map((viewer) => ( - - - {canManage ? ( - - event.target.value !== viewer.name && - viewerEdit.run(() => - api.send("PATCH", `viewers/${viewer.id}`, { - name: event.target.value, - }), - ) - } - /> - ) : ( - viewer.name - )} - - - - - - {canManage ? ( - - ) : ( - (channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—") - )} - - {formatSince(viewer.lastSeenAtMs, nowMs)} - - - {viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"} - - - - {viewer.lastError === null ? ( - "—" - ) : ( - - {viewer.lastError.code} - - )} - - - ))} - -
- ), - { label: "Loading displays…", onRetry: viewers.reload }, - )} + {renderLoadable(viewers.state, () => null, { + label: "Loading displays…", + onRetry: viewers.reload, + })}
diff --git a/src/ui/styles.css b/src/ui/styles.css index 07279e0..c13f4ff 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -335,24 +335,112 @@ gap: var(--signage-gap); } -.signage-group { - border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); - border-radius: var(--ekklesia-radius-md); +.signage-pairing-steps { display: grid; gap: var(--signage-gap-tight); + grid-template-columns: repeat(3, minmax(0, 1fr)); + list-style: none; + margin: 0; + padding: 0; +} + +.signage-pairing-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--signage-gap-tight); +} + +.signage-pairing-actions a { + text-decoration: none; +} + +.signage-copy-status { + color: var(--ekklesia-text-secondary); + display: block; + font-size: var(--ekklesia-typography-size-sm); + min-height: 1.25em; + margin-block-start: var(--ekklesia-spacing-xs); +} + +.signage-pairing-steps li { + align-items: center; + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-md); + display: flex; + gap: var(--signage-gap-tight); + min-width: 0; padding: var(--ekklesia-spacing-md); } +.signage-pairing-steps__number { + align-items: center; + background: var(--ekklesia-accent-base); + border-radius: var(--ekklesia-radius-pill); + color: var(--ekklesia-accent-foreground); + display: inline-flex; + flex: 0 0 var(--ekklesia-control-height-sm); + font-weight: var(--ekklesia-typography-weight-bold); + height: var(--ekklesia-control-height-sm); + justify-content: center; +} + +.signage-display-empty { + align-items: baseline; + color: var(--ekklesia-text-secondary); + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-xs) var(--signage-gap-tight); + margin-block-start: var(--signage-gap-tight); +} + +.signage-display-empty strong { + color: var(--ekklesia-text-primary); +} + +.signage-new-group > .ek-field { + min-width: 16rem; +} + +.signage-group { + background: var(--ekklesia-background-canvas); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-radius: var(--signage-panel-radius); + display: grid; + gap: var(--signage-gap); + padding: var(--ekklesia-spacing-lg); +} + .signage-group__header { align-items: center; display: flex; gap: var(--signage-gap-tight); } -.signage-group__header .ek-button:first-of-type { +.signage-group__header > .ek-button { margin-inline-start: auto; } +.signage-group__identity { + display: grid; + gap: var(--ekklesia-spacing-2xs); +} + +.signage-group__identity h3, +.signage-audiences__heading h4, +.signage-channel__header h5, +.signage-channel-form__heading h5 { + margin: 0; +} + +.signage-hierarchy-label { + color: var(--ekklesia-text-muted); + font-size: var(--ekklesia-typography-size-xs); + font-weight: var(--ekklesia-typography-weight-medium); + letter-spacing: 0.05em; + text-transform: uppercase; +} + .signage-confirm { align-items: center; background: var(--ekklesia-status-danger-background); @@ -369,18 +457,160 @@ margin: 0; } -.signage-registration-guide { - background: var(--ekklesia-background-inset); +.signage-audiences { + border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + display: grid; + gap: var(--signage-gap); + padding-block-start: var(--signage-gap); +} + +.signage-audiences__heading { + align-items: end; + display: flex; + gap: var(--signage-gap); + justify-content: space-between; +} + +.signage-audiences__heading > div { + display: grid; + gap: var(--ekklesia-spacing-2xs); +} + +.signage-audiences__heading > span { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-channel-list { + display: grid; + gap: var(--signage-gap-tight); +} + +.signage-channel { + background: var(--ekklesia-background-surface); + border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + border-inline-start: var(--ekklesia-spacing-2xs) solid var(--ekklesia-accent-base); border-radius: var(--ekklesia-radius-md); display: grid; - gap: var(--ekklesia-spacing-xs); - margin-block-end: var(--signage-gap); + gap: var(--signage-gap-tight); + margin-inline-start: var(--ekklesia-spacing-sm); padding: var(--ekklesia-spacing-md); } -.signage-registration-guide ol { +.signage-channel__header { + align-items: start; + display: flex; + gap: var(--signage-gap-tight); + justify-content: space-between; +} + +.signage-channel__header > div { + display: grid; + gap: var(--ekklesia-spacing-xs); +} + +.signage-channel__meta { + align-items: center; + color: var(--ekklesia-text-secondary); + display: flex; + flex-wrap: wrap; + font-size: var(--ekklesia-typography-size-sm); + gap: var(--ekklesia-spacing-xs); +} + +.signage-channel__meta code, +.signage-pairing-steps code { + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-sm); + color: var(--ekklesia-text-primary); + padding: var(--ekklesia-spacing-2xs) var(--ekklesia-spacing-xs); +} + +.signage-channel__displays { + border-block-start: var(--ekklesia-border-width) solid var(--ekklesia-border-divider); + display: grid; + gap: var(--signage-gap-tight); + padding-block-start: var(--signage-gap-tight); +} + +.signage-channel__empty { + color: var(--ekklesia-text-muted); + font-size: var(--ekklesia-typography-size-sm); margin: 0; - padding-inline-start: var(--ekklesia-spacing-xl); +} + +.signage-display-row { + align-items: end; + background: var(--ekklesia-background-canvas); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--signage-gap-tight); + grid-template-columns: minmax(14rem, 1.2fr) minmax(14rem, 1fr) minmax(14rem, 1.2fr); + padding: var(--ekklesia-spacing-sm); +} + +.signage-display-row__identity, +.signage-display-row__health { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--ekklesia-spacing-xs); +} + +.signage-display-row__identity .ek-control, +.signage-display-row__assignment .ek-field { + width: 100%; +} + +.signage-display-row__health { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-display-row__error { + color: var(--ekklesia-text-danger); +} + +.signage-channel-form, +.signage-unassigned { + background: var(--ekklesia-background-inset); + border-radius: var(--ekklesia-radius-md); + display: grid; + gap: var(--signage-gap-tight); + margin-inline-start: var(--ekklesia-spacing-sm); + padding: var(--ekklesia-spacing-md); +} + +.signage-channel-form { + align-items: start; + grid-template-columns: minmax(16rem, 2fr) minmax(14rem, 1fr); +} + +.signage-channel-form__heading, +.signage-channel-form > .ek-button { + grid-column: 1 / -1; +} + +.signage-channel-form__heading { + display: grid; + gap: var(--ekklesia-spacing-2xs); +} + +.signage-channel-form__heading span { + color: var(--ekklesia-text-secondary); + font-size: var(--ekklesia-typography-size-sm); +} + +.signage-channel-form > .ek-button { + justify-self: start; +} + +@media (max-width: 1200px) { + .signage-pairing-steps, + .signage-channel-form, + .signage-display-row { + grid-template-columns: 1fr; + } } .signage-provider__id { diff --git a/tests/browser/management-ui.test.tsx b/tests/browser/management-ui.test.tsx index 8a4604d..049e598 100644 --- a/tests/browser/management-ui.test.tsx +++ b/tests/browser/management-ui.test.tsx @@ -18,6 +18,21 @@ import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./su let container: HTMLElement; let root: Root; +const DISPLAY_SETUP_URL = "https://dashboard.example.test/display/signage"; +const copiedText: string[] = []; + +function dashboard(context: Parameters[0]["context"]): React.ReactElement { + return ( + { + copiedText.push(value); + return Promise.resolve(); + }} + displaySetupUrl={DISPLAY_SETUP_URL} + /> + ); +} async function render(element: React.ReactElement): Promise { await act(async () => { @@ -131,6 +146,7 @@ const TIMELINE = { }; beforeEach(() => { + copiedText.length = 0; container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -168,7 +184,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain( "welcome", @@ -197,7 +213,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); // The current slide is still legible; the buttons that would be refused are simply not offered. expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull(); @@ -220,7 +236,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); expect(container.textContent).toContain("Active: Sunday morning"); await click( [...container.querySelectorAll("[role='tab']")].find( @@ -262,7 +278,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (candidate) => candidate.textContent === "Timelines", @@ -293,7 +309,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Timelines", @@ -357,7 +373,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Timelines", @@ -407,7 +423,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Providers", @@ -430,7 +446,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (tab) => tab.textContent === "Providers", @@ -476,13 +492,15 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"), ); expect(container.textContent).toContain("Online"); expect(container.textContent).toContain("12 cached"); + const display = container.querySelector("[data-testid='signage-display-viewer-1']"); + expect(display?.closest(".signage-channel")?.textContent).toContain("Default"); // A sanitized code, never a stack or a message a browser happened to have. expect(container.textContent).toContain("SLIDE_DECODE_FAILED"); }); @@ -521,15 +539,27 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find( (candidate) => candidate.textContent === "Displays", ), ); - expect(container.textContent).toContain("/display/signage"); - expect(container.textContent).toContain("Provider audience key"); + const setupLink = [...container.querySelectorAll("a")].find( + (candidate) => candidate.textContent?.trim() === "Open display setup", + ); + expect(setupLink?.href).toBe(DISPLAY_SETUP_URL); + expect(setupLink?.target).toBe("_blank"); + expect(setupLink?.rel).toContain("noopener"); + await click(buttonNamed("Copy URL")); + expect(copiedText).toEqual([DISPLAY_SETUP_URL]); + expect(container.textContent).toContain("Display setup URL copied."); + expect(container.textContent).toContain("Channel groups and displays"); + expect(container.textContent).toContain("Audience channels"); + expect(container.textContent).toContain("Channel name"); + expect(container.textContent).toContain("Audience key"); + expect(container.textContent).toContain("Advanced: lowercase ID sent to content providers"); await click(buttonNamed("Delete group")); expect(deleted).toEqual([]); expect(container.textContent).toContain("Displays assigned here will move"); @@ -564,7 +594,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); await click( [...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"), ); @@ -615,7 +645,7 @@ describe("Signage dashboard", () => { ], }); - await render(); + await render(dashboard(host.context)); expect(container.textContent).toContain("Signage could not load this"); expect(container.textContent).toContain("The operation could not be completed.");