mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-27 00:36:34 +00:00
feat: AbleSign dropdown nav + screens/content/playlists pages
- Sidebar: NavGroup component (details/summary) for AbleSign dropdown with Accounts, Screens, Content, Playlists sub-items - Global screens page (/admin/ablesign/screens) — all screens across accounts with Internal/External badge - Content page — aggregates media files + web apps from all accounts - Playlists page — shows per-screen playlist items - Auto-sync screens on account creation - Internal/External: Internal = created via "Create & Pair" (has screenToken, gets entity). External = synced from AbleSign (no token, no entity, management-only). Only internal screens become entities. - Entity creation only on Create & Pair path — not on sync or assign Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e1a3cd1d05
commit
e0941f533d
4 changed files with 197 additions and 28 deletions
|
|
@ -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 }));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Entity | null> {
|
||||
const r = await this._get(
|
||||
`SELECT * FROM entities WHERE type = 'ablesign' AND ablesign_screen_id = ? LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
return r ? rowToEntity(r as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
async ensureCameraEntity(camera: Camera): Promise<Entity> {
|
||||
const existing = await this.getEntityForCamera(camera.id);
|
||||
if (existing) return existing;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Layout title={`AbleSign — ${String(a.name)}`} activeNav="ablesign">
|
||||
<h1 style="font-size:1.5rem; margin:0 0 0.5rem">{a.name} — Screens</h1>
|
||||
<p style="color:#999; margin:0 0 1.5rem; font-size:0.85rem">
|
||||
{String(a.screen_count ?? 0)} screens
|
||||
{a.last_sync_at && ` · synced ${formatTime(a.last_sync_at)}`}
|
||||
</p>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="font-size:1rem; margin:0 0 0.75rem">Add Screen</h2>
|
||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
|
||||
<label style="font-size:0.85rem">
|
||||
{"Screen Name"}<br/>
|
||||
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
|
||||
</form>
|
||||
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
|
||||
Creates a new screen in AbleSign and pairs it automatically.
|
||||
<Layout title={title} activeNav={isGlobal ? "ablesign-screens" : "ablesign"}>
|
||||
<h1 style="font-size:1.5rem; margin:0 0 0.5rem">{isGlobal ? "All AbleSign Screens" : `${String(a.name)} — Screens`}</h1>
|
||||
{a ? (
|
||||
<p style="color:#999; margin:0 0 1.5rem; font-size:0.85rem">
|
||||
{String(a.screen_count ?? 0)} screens
|
||||
{a.last_sync_at ? ` · synced ${formatTime(a.last_sync_at)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : ""}
|
||||
|
||||
{a ? (
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="font-size:1rem; margin:0 0 0.75rem">Add Screen</h2>
|
||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
|
||||
<label style="font-size:0.85rem">
|
||||
{"Screen Name"}<br/>
|
||||
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
|
||||
</form>
|
||||
</div>
|
||||
) : ""}
|
||||
|
||||
<div class="card" style="margin-bottom:1rem">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem">
|
||||
<h2 style="font-size:1rem; margin:0">Screens</h2>
|
||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`}>
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
|
||||
</form>
|
||||
{a ? (
|
||||
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`}>
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
|
||||
</form>
|
||||
) : ""}
|
||||
</div>
|
||||
|
||||
{props.screens.length === 0 ? (
|
||||
|
|
@ -4499,6 +4505,7 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
|||
<th>Title</th>
|
||||
<th>Orientation</th>
|
||||
<th>Status</th>
|
||||
<th>Source</th>
|
||||
<th>Assigned Kiosk</th>
|
||||
<th>Actions</th>
|
||||
</tr></thead>
|
||||
|
|
@ -4512,6 +4519,11 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
|||
? <span class="badge badge-green">Online</span>
|
||||
: <span class="badge badge-gray">Offline</span>}
|
||||
</td>
|
||||
<td>
|
||||
{s.has_entity
|
||||
? <span class="badge badge-blue">Internal</span>
|
||||
: <span class="badge badge-gray">External</span>}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/assign`}
|
||||
style="display:flex; gap:0.25rem; align-items:center">
|
||||
|
|
@ -4539,3 +4551,59 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
|
|||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
interface AbleSignContentPageProps { content: any[]; accounts: any[]; }
|
||||
|
||||
export function AbleSignContentPage(props: AbleSignContentPageProps) {
|
||||
return (
|
||||
<Layout title="AbleSign — Content" activeNav="ablesign-content">
|
||||
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Content</h1>
|
||||
<div class="card">
|
||||
{props.content.length === 0
|
||||
? <p style="color:#999; font-size:0.85rem">No content found. Add media or web apps in AbleSign CMS.</p>
|
||||
: <div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Type</th><th>Account</th></tr></thead>
|
||||
<tbody>
|
||||
{props.content.map((c: any) => (
|
||||
<tr>
|
||||
<td>{c.title}</td>
|
||||
<td style="font-size:0.85rem">{c.kind === "media" ? String(c.fileType || "media") : "web app"}</td>
|
||||
<td style="font-size:0.85rem; color:#999">{c.account_name}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
interface AbleSignPlaylistsPageProps { playlists: any[]; }
|
||||
|
||||
export function AbleSignPlaylistsPage(props: AbleSignPlaylistsPageProps) {
|
||||
const cards = props.playlists.map((pl: any) =>
|
||||
`<div class="card" style="margin-bottom:1rem">
|
||||
<h2 style="font-size:1rem; margin:0 0 0.5rem">${pl.screen_title as string}</h2>
|
||||
<p style="font-size:0.8rem; color:#999; margin:0 0 0.5rem">
|
||||
Account: ${pl.account_name as string} · ${String(pl.items?.length ?? 0)} items${pl.shufflePlay ? " · Shuffle" : ""}
|
||||
</p>
|
||||
${Array.isArray(pl.items) && pl.items.length > 0
|
||||
? `<table style="font-size:0.85rem; width:100%"><thead><tr><th>#</th><th>Type</th><th>Duration</th></tr></thead><tbody>${
|
||||
(pl.items as any[]).map((item: any, idx: number) =>
|
||||
`<tr><td>${String(idx + 1)}</td><td>${item.mediafileId ? "Media" : item.webAppId ? "Web App" : "Unknown"}</td><td>${item.displayDuration ? `${String(item.displayDuration)}s` : "—"}</td></tr>`
|
||||
).join("")
|
||||
}</tbody></table>`
|
||||
: ""}
|
||||
</div>`
|
||||
).join("");
|
||||
return (
|
||||
<Layout title="AbleSign — Playlists" activeNav="ablesign-playlists">
|
||||
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign Playlists</h1>
|
||||
{props.playlists.length === 0
|
||||
? <div class="card"><p style="color:#999; font-size:0.85rem">No playlists found.</p></div>
|
||||
: cards}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<details class="nav-group" open={props.active}>
|
||||
<summary class={`nav-item${props.active ? " active" : ""}`}>
|
||||
<span class="nav-icon">{props.icon}</span>
|
||||
{props.label}
|
||||
</summary>
|
||||
<div class="nav-group-items">
|
||||
{props.children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar(props: { activeNav?: string }) {
|
||||
const a = props.activeNav;
|
||||
return (
|
||||
|
|
@ -58,7 +72,12 @@ function Sidebar(props: { activeNav?: string }) {
|
|||
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
||||
<NavItem href="/admin/os-updates" label="OS Updates" icon="●" active={a === "os-updates"} />
|
||||
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="☁" active={a === "cloud"} />
|
||||
<NavItem href="/admin/ablesign" label="AbleSign" icon="▶" active={a === "ablesign"} />
|
||||
<NavGroup label="AbleSign" icon="▶" active={a?.startsWith("ablesign")}>
|
||||
<NavItem href="/admin/ablesign" label="Accounts" icon=" " active={a === "ablesign"} />
|
||||
<NavItem href="/admin/ablesign/screens" label="Screens" icon=" " active={a === "ablesign-screens"} />
|
||||
<NavItem href="/admin/ablesign/content" label="Content" icon=" " active={a === "ablesign-content"} />
|
||||
<NavItem href="/admin/ablesign/playlists" label="Playlists" icon=" " active={a === "ablesign-playlists"} />
|
||||
</NavGroup>
|
||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue