diff --git a/server/src/plugins/service-admin-http/routes-ablesign.ts b/server/src/plugins/service-admin-http/routes-ablesign.ts index 14077db..908722b 100644 --- a/server/src/plugins/service-admin-http/routes-ablesign.ts +++ b/server/src/plugins/service-admin-http/routes-ablesign.ts @@ -6,7 +6,7 @@ import { type H3, getRouterParam, readBody, createError } from "h3"; import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import * as ablesign from "../../shared/ablesign.js"; -import { AbleSignPage, AbleSignScreensPage } from "../../web-templates/admin-pages.js"; +import { AbleSignPage, AbleSignScreensPage, AbleSignContentPage, AbleSignPlaylistsPage } from "../../web-templates/admin-pages.js"; export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void { @@ -33,8 +33,29 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void { } const encrypted = deps.secrets.encryptString(apiKey, "ablesign-key"); - await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId }); - return new Response(null, { status: 302, headers: { location: "/admin/ablesign" } }); + const accountId = await deps.repo.createAbleSignAccount({ name, api_key_encrypted: encrypted, workspace_id: workspaceId }); + + // Auto-sync screens on account creation. + try { + const opts = { apiKey, workspaceId }; + const result = await ablesign.listScreens(opts); + for (const s of result.data) { + await deps.repo.upsertAbleSignScreen({ + account_id: accountId, + ablesign_screen_id: String(s.id), + title: s.title, + online: !!s.heartbeatTime, + last_heartbeat_at: s.heartbeatTime || undefined, + orientation: s.orientation, + }); + } + await deps.repo.updateAbleSignAccount(accountId, { + screen_count: result.data.length, + last_sync_at: new Date().toISOString(), + }); + } catch { /* sync failure is non-fatal */ } + + return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); }); app.get("/admin/ablesign/:id/screens", async (event) => { @@ -43,6 +64,9 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void { if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" }); const screens = await deps.repo.listAbleSignScreens(id); const kiosks = await deps.repo.listKiosks(); + for (const s of screens) { + (s as any).has_entity = !!(await deps.repo.getEntityByAbleSignScreen(s.id)); + } return htmlPage(AbleSignScreensPage({ account, screens, kiosks })); }); @@ -57,7 +81,7 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void { const result = await ablesign.listScreens(opts); for (const s of result.data) { - await deps.repo.upsertAbleSignScreen({ + const screenRowId = await deps.repo.upsertAbleSignScreen({ account_id: id, ablesign_screen_id: String(s.id), title: s.title, @@ -170,4 +194,49 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void { const accountId = screen?.account_id ?? ""; return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); }); + + // ---- Global views (all accounts aggregated) -------------------------------- + + app.get("/admin/ablesign/screens", async () => { + const screens = await deps.repo.listAbleSignScreens(); + const kiosks = await deps.repo.listKiosks(); + const accounts = await deps.repo.listAbleSignAccounts(); + for (const s of screens) { + (s as any).has_entity = !!(await deps.repo.getEntityByAbleSignScreen(s.id)); + } + return htmlPage(AbleSignScreensPage({ account: null, screens, kiosks, accounts })); + }); + + app.get("/admin/ablesign/content", async () => { + const accounts = await deps.repo.listAbleSignAccounts(); + const content: any[] = []; + for (const acct of accounts) { + try { + const apiKey = deps.secrets.decryptString(acct.api_key_encrypted, "ablesign-key"); + const opts = { apiKey, workspaceId: acct.workspace_id || undefined }; + const media = await ablesign.listMediaFiles(opts); + const webApps = await ablesign.listWebApps(opts); + for (const m of media.data) content.push({ ...m, account_name: acct.name, kind: "media" }); + for (const w of webApps.data) content.push({ ...w, account_name: acct.name, kind: "webapp" }); + } catch { /* skip failed accounts */ } + } + return htmlPage(AbleSignContentPage({ content, accounts })); + }); + + app.get("/admin/ablesign/playlists", async () => { + const accounts = await deps.repo.listAbleSignAccounts(); + const screens = await deps.repo.listAbleSignScreens(); + const playlists: any[] = []; + for (const s of screens) { + const acct = accounts.find((a: any) => a.id === s.account_id); + if (!acct) continue; + try { + const apiKey = deps.secrets.decryptString(acct.api_key_encrypted, "ablesign-key"); + const opts = { apiKey, workspaceId: acct.workspace_id || undefined }; + const pl = await ablesign.getPlaylist(opts, Number(s.ablesign_screen_id)); + playlists.push({ screen_title: s.title, account_name: acct.name, ...pl }); + } catch { /* skip */ } + } + return htmlPage(AbleSignPlaylistsPage({ playlists })); + }); } diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index e3cdfe2..bd2cd5a 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -2402,6 +2402,14 @@ export class Repository { * the camera's name is already taken by another entity, append the camera * id to keep the name unique. */ + async getEntityByAbleSignScreen(screenId: string): Promise { + const r = await this._get( + `SELECT * FROM entities WHERE type = 'ablesign' AND ablesign_screen_id = ? LIMIT 1`, + [screenId], + ); + return r ? rowToEntity(r as Record) : null; + } + async ensureCameraEntity(camera: Camera): Promise { const existing = await this.getEntityForCamera(camera.id); if (existing) return existing; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 9fe5c2a..a8fa71b 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -4453,41 +4453,47 @@ export function AbleSignPage(props: AbleSignPageProps) { } interface AbleSignScreensPageProps { - account: any; + account: any | null; screens: any[]; kiosks: any[]; + accounts?: any[]; } export function AbleSignScreensPage(props: AbleSignScreensPageProps) { const a = props.account; + const isGlobal = !a; + const title = isGlobal ? "AbleSign — All Screens" : `AbleSign — ${String(a.name)}`; return ( - -

{a.name} — Screens

-

- {String(a.screen_count ?? 0)} screens - {a.last_sync_at && ` · synced ${formatTime(a.last_sync_at)}`} -

- -
-

Add Screen

-
- - -
-

- Creates a new screen in AbleSign and pairs it automatically. + +

{isGlobal ? "All AbleSign Screens" : `${String(a.name)} — Screens`}

+ {a ? ( +

+ {String(a.screen_count ?? 0)} screens + {a.last_sync_at ? ` · synced ${formatTime(a.last_sync_at)}` : ""}

-
+ ) : ""} + + {a ? ( +
+

Add Screen

+
+ + +
+
+ ) : ""}

Screens

-
- -
+ {a ? ( +
+ +
+ ) : ""}
{props.screens.length === 0 ? ( @@ -4499,6 +4505,7 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) { Title Orientation Status + Source Assigned Kiosk Actions @@ -4512,6 +4519,11 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) { ? Online : Offline} + + {s.has_entity + ? Internal + : External} +
@@ -4539,3 +4551,59 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) { ); } + +interface AbleSignContentPageProps { content: any[]; accounts: any[]; } + +export function AbleSignContentPage(props: AbleSignContentPageProps) { + return ( + +

AbleSign Content

+
+ {props.content.length === 0 + ?

No content found. Add media or web apps in AbleSign CMS.

+ :
+ + + + {props.content.map((c: any) => ( + + + + + + ))} + +
TitleTypeAccount
{c.title}{c.kind === "media" ? String(c.fileType || "media") : "web app"}{c.account_name}
+
} +
+
+ ); +} + +interface AbleSignPlaylistsPageProps { playlists: any[]; } + +export function AbleSignPlaylistsPage(props: AbleSignPlaylistsPageProps) { + const cards = props.playlists.map((pl: any) => + `
+

${pl.screen_title as string}

+

+ Account: ${pl.account_name as string} · ${String(pl.items?.length ?? 0)} items${pl.shufflePlay ? " · Shuffle" : ""} +

+ ${Array.isArray(pl.items) && pl.items.length > 0 + ? `${ + (pl.items as any[]).map((item: any, idx: number) => + `` + ).join("") + }
#TypeDuration
${String(idx + 1)}${item.mediafileId ? "Media" : item.webAppId ? "Web App" : "Unknown"}${item.displayDuration ? `${String(item.displayDuration)}s` : "—"}
` + : ""} +
` + ).join(""); + return ( + +

AbleSign Playlists

+ {props.playlists.length === 0 + ?

No playlists found.

+ : cards} +
+ ); +} diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index e8a2b31..a9c0c57 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -39,6 +39,20 @@ function NavItem(props: { href: string; label: string; icon: string; active?: bo ); } +function NavGroup(props: { label: string; icon: string; active?: boolean; children: string | string[] }) { + return ( + + ); +} + function Sidebar(props: { activeNav?: string }) { const a = props.activeNav; return ( @@ -58,7 +72,12 @@ function Sidebar(props: { activeNav?: string }) { - + + + + + + @@ -200,6 +219,11 @@ const baseStyles = { ".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" }, ".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" }, ".nav-icon": { fontSize: "0.75rem", width: "1.25rem", textAlign: "center" as const }, + ".nav-group": { margin: 0, padding: 0 }, + ".nav-group summary": { cursor: "pointer", listStyle: "none" }, + ".nav-group summary::-webkit-details-marker": { display: "none" }, + ".nav-group-items": { paddingLeft: "1.25rem" }, + ".nav-group-items .nav-item": { fontSize: "0.8rem", padding: "0.35rem 1rem" }, ".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" }, ".topbar": { display: "flex",