refactor: AbleSign UI — single account, screen detail, no kiosk assign

- Remove Accounts from AbleSign nav (one account per tenant)
- Screens page: create button, no kiosk assignment
- Screen detail page with config form
- Internal/External badge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-27 02:09:40 +02:00
parent e0941f533d
commit 65de42d495
3 changed files with 140 additions and 55 deletions

View file

@ -6,7 +6,7 @@ import { type H3, getRouterParam, readBody, createError } from "h3";
import { htmlPage } from "./html-response.js"; import { htmlPage } from "./html-response.js";
import type { AdminDeps } from "./index.js"; import type { AdminDeps } from "./index.js";
import * as ablesign from "../../shared/ablesign.js"; import * as ablesign from "../../shared/ablesign.js";
import { AbleSignPage, AbleSignScreensPage, AbleSignContentPage, AbleSignPlaylistsPage } from "../../web-templates/admin-pages.js"; import { AbleSignPage, AbleSignScreensPage, AbleSignScreenDetailPage, AbleSignContentPage, AbleSignPlaylistsPage } from "../../web-templates/admin-pages.js";
export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void { export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
@ -55,19 +55,12 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
}); });
} catch { /* sync failure is non-fatal */ } } catch { /* sync failure is non-fatal */ }
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
}); });
app.get("/admin/ablesign/:id/screens", async (event) => { // Redirect old per-account route to global screens page.
const id = getRouterParam(event, "id") ?? ""; app.get("/admin/ablesign/:id/screens", async () => {
const account = await deps.repo.getAbleSignAccount(id); return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
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 }));
}); });
app.post("/admin/ablesign/:id/sync", async (event) => { app.post("/admin/ablesign/:id/sync", async (event) => {
@ -114,7 +107,7 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
const title = (body?.title ?? "").trim(); const title = (body?.title ?? "").trim();
if (!title) { if (!title) {
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
} }
try { try {
@ -155,7 +148,7 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
// redirect back — error handling TODO // redirect back — error handling TODO
} }
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
}); });
app.post("/admin/ablesign/screens/:sid/assign", async (event) => { app.post("/admin/ablesign/screens/:sid/assign", async (event) => {
@ -166,7 +159,55 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
const screen = await deps.repo.getAbleSignScreen(sid); const screen = await deps.repo.getAbleSignScreen(sid);
const accountId = screen?.account_id ?? ""; const accountId = screen?.account_id ?? "";
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
});
// ---- Screen detail + config -------------------------------------------------
app.get("/admin/ablesign/screens/:sid", async (event) => {
const sid = getRouterParam(event, "sid") ?? "";
const screen = await deps.repo.getAbleSignScreen(sid);
if (!screen) throw createError({ statusCode: 404, statusMessage: "Screen not found" });
const account = await deps.repo.getAbleSignAccount(screen.account_id);
let remoteScreen: any = null;
if (account) {
try {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
remoteScreen = await ablesign.getScreen(
{ apiKey, workspaceId: account.workspace_id || undefined },
Number(screen.ablesign_screen_id),
);
} catch { /* remote fetch failed */ }
}
const entity = await deps.repo.getEntityByAbleSignScreen(sid);
return htmlPage(AbleSignScreenDetailPage({ screen, remoteScreen, entity }));
});
app.post("/admin/ablesign/screens/:sid", async (event) => {
const sid = getRouterParam(event, "sid") ?? "";
const screen = await deps.repo.getAbleSignScreen(sid);
if (!screen) throw createError({ statusCode: 404, statusMessage: "Screen not found" });
const account = await deps.repo.getAbleSignAccount(screen.account_id);
if (!account) throw createError({ statusCode: 404, statusMessage: "Account not found" });
const body = await readBody<Record<string, string>>(event);
const title = (body?.title ?? "").trim();
const orientation = body?.orientation ?? "landscape";
const description = (body?.description ?? "").trim();
try {
const apiKey = deps.secrets.decryptString(account.api_key_encrypted, "ablesign-key");
await ablesign.updateScreen(
{ apiKey, workspaceId: account.workspace_id || undefined },
Number(screen.ablesign_screen_id),
{ title: title || undefined, orientation, description: description || undefined },
);
if (title) {
await deps.repo.updateAbleSignScreen(sid, { title, orientation });
}
} catch { /* update failed */ }
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/screens/${sid}` } });
}); });
app.post("/admin/ablesign/:id/delete", async (event) => { app.post("/admin/ablesign/:id/delete", async (event) => {
@ -192,19 +233,19 @@ export function registerAbleSignRoutes(app: H3, deps: AdminDeps): void {
await deps.repo.deleteAbleSignScreen(sid); await deps.repo.deleteAbleSignScreen(sid);
} }
const accountId = screen?.account_id ?? ""; const accountId = screen?.account_id ?? "";
return new Response(null, { status: 302, headers: { location: `/admin/ablesign/${accountId}/screens` } }); return new Response(null, { status: 302, headers: { location: "/admin/ablesign/screens" } });
}); });
// ---- Global views (all accounts aggregated) -------------------------------- // ---- Global views (all accounts aggregated) --------------------------------
app.get("/admin/ablesign/screens", async () => { app.get("/admin/ablesign/screens", async () => {
const screens = await deps.repo.listAbleSignScreens();
const kiosks = await deps.repo.listKiosks();
const accounts = await deps.repo.listAbleSignAccounts(); const accounts = await deps.repo.listAbleSignAccounts();
const account = accounts[0] ?? null;
const screens = account ? await deps.repo.listAbleSignScreens(account.id) : [];
for (const s of screens) { for (const s of screens) {
(s as any).has_entity = !!(await deps.repo.getEntityByAbleSignScreen(s.id)); (s as any).has_entity = !!(await deps.repo.getEntityByAbleSignScreen(s.id));
} }
return htmlPage(AbleSignScreensPage({ account: null, screens, kiosks, accounts })); return htmlPage(AbleSignScreensPage({ screens, accountId: account?.id ?? null }));
}); });
app.get("/admin/ablesign/content", async () => { app.get("/admin/ablesign/content", async () => {

View file

@ -4453,51 +4453,51 @@ export function AbleSignPage(props: AbleSignPageProps) {
} }
interface AbleSignScreensPageProps { interface AbleSignScreensPageProps {
account: any | null;
screens: any[]; screens: any[];
kiosks: any[]; accountId: string | null;
accounts?: any[]; error?: string;
} }
export function AbleSignScreensPage(props: AbleSignScreensPageProps) { export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
const a = props.account; const aid = props.accountId;
const isGlobal = !a;
const title = isGlobal ? "AbleSign — All Screens" : `AbleSign — ${String(a.name)}`;
return ( return (
<Layout title={title} activeNav={isGlobal ? "ablesign-screens" : "ablesign"}> <Layout title="AbleSign — Screens" activeNav="ablesign-screens">
<h1 style="font-size:1.5rem; margin:0 0 0.5rem">{isGlobal ? "All AbleSign Screens" : `${String(a.name)} — Screens`}</h1> <h1 style="font-size:1.5rem; margin:0 0 1.5rem">AbleSign 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>
) : ""}
{a ? ( {props.error ? <div class="alert alert-error" style="margin-bottom:1rem">{props.error}</div> : ""}
{!aid ? (
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Add Screen</h2> <p style="color:#999; font-size:0.85rem">No AbleSign account configured. Add one under Account settings first.</p>
<form method="POST" action={`/admin/ablesign/${String(a.id)}/screens/add`} style="display:flex; gap:0.5rem; align-items:end"> </div>
) : (
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Create Screen</h2>
<form method="POST" action={`/admin/ablesign/${aid}/screens/add`} style="display:flex; gap:0.5rem; align-items:end">
<label style="font-size:0.85rem"> <label style="font-size:0.85rem">
{"Screen Name"}<br/> {"Screen Name"}<br/>
<input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" /> <input type="text" name="title" required style="width:16rem" placeholder="Lobby Display" />
</label> </label>
<button type="submit" class="btn btn-sm">{"Create & Pair"}</button> <button type="submit" class="btn btn-sm">{"Create & Pair"}</button>
</form> </form>
<p style="font-size:0.8rem; color:#999; margin:0.5rem 0 0">
Registers a new screen in AbleSign headlessly and creates a linked entity for use in layouts.
</p>
</div> </div>
) : ""} )}
<div class="card" style="margin-bottom:1rem"> <div class="card" style="margin-bottom:1rem">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem">
<h2 style="font-size:1rem; margin:0">Screens</h2> <h2 style="font-size:1rem; margin:0">Screens</h2>
{a ? ( {aid ? (
<form method="POST" action={`/admin/ablesign/${String(a.id)}/sync`}> <form method="POST" action={`/admin/ablesign/${aid}/sync`}>
<button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button> <button type="submit" class="btn btn-sm btn-ghost">Sync from AbleSign</button>
</form> </form>
) : ""} ) : ""}
</div> </div>
{props.screens.length === 0 ? ( {props.screens.length === 0 ? (
<p style="color:#999; font-size:0.85rem">No screens yet. Add one above or sync from AbleSign.</p> <p style="color:#999; font-size:0.85rem">No screens yet. Create one above or sync from AbleSign.</p>
) : ( ) : (
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@ -4506,13 +4506,12 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
<th>Orientation</th> <th>Orientation</th>
<th>Status</th> <th>Status</th>
<th>Source</th> <th>Source</th>
<th>Assigned Kiosk</th>
<th>Actions</th> <th>Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{props.screens.map((s: any) => ( {props.screens.map((s: any) => (
<tr> <tr>
<td>{s.title}</td> <td><a href={`/admin/ablesign/screens/${String(s.id)}`}>{s.title}</a></td>
<td style="font-size:0.85rem">{s.orientation}</td> <td style="font-size:0.85rem">{s.orientation}</td>
<td> <td>
{s.online {s.online
@ -4524,18 +4523,6 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
? <span class="badge badge-blue">Internal</span> ? <span class="badge badge-blue">Internal</span>
: <span class="badge badge-gray">External</span>} : <span class="badge badge-gray">External</span>}
</td> </td>
<td>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/assign`}
style="display:flex; gap:0.25rem; align-items:center">
<select name="kiosk_id" style="font-size:0.85rem; max-width:14rem">
<option value=""> None </option>
{props.kiosks.map((k: any) => (
<option value={String(k.id)} selected={k.id === s.kiosk_id}>{k.name}</option>
))}
</select>
<button type="submit" class="btn btn-sm btn-ghost">Assign</button>
</form>
</td>
<td> <td>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/delete`} style="display:inline"> <form method="POST" action={`/admin/ablesign/screens/${String(s.id)}/delete`} style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button> <button type="submit" class="btn btn-sm btn-ghost" style="color:#c00">Delete</button>
@ -4552,6 +4539,64 @@ export function AbleSignScreensPage(props: AbleSignScreensPageProps) {
); );
} }
// ---- AbleSign Screen Detail Page ---------------------------------------------
interface AbleSignScreenDetailPageProps {
screen: any;
remoteScreen: any | null;
entity: any | null;
}
export function AbleSignScreenDetailPage(props: AbleSignScreenDetailPageProps) {
const s = props.screen;
const r = props.remoteScreen;
return (
<Layout title={`AbleSign — ${String(s.title)}`} activeNav="ablesign-screens">
<h1 style="font-size:1.5rem; margin:0 0 1.5rem">{s.title}</h1>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.75rem">Screen Configuration</h2>
<form method="POST" action={`/admin/ablesign/screens/${String(s.id)}`}>
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin-bottom:1rem">
<label style="font-size:0.85rem">
{"Title"}<br/>
<input type="text" name="title" value={s.title} style="width:16rem" />
</label>
<label style="font-size:0.85rem">
{"Orientation"}<br/>
<select name="orientation" style="font-size:0.85rem">
<option value="landscape" selected={s.orientation === "landscape"}>Landscape</option>
<option value="portrait" selected={s.orientation === "portrait"}>Portrait</option>
</select>
</label>
<label style="font-size:0.85rem">
{"Description"}<br/>
<input type="text" name="description" value={r?.description ?? ""} style="width:20rem" placeholder="Optional" />
</label>
</div>
<button type="submit" class="btn btn-sm">Save</button>
</form>
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1rem; margin:0 0 0.5rem">Status</h2>
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666">
<div>{"AbleSign ID: "}{String(s.ablesign_screen_id)}</div>
<div>{"Status: "}{s.online ? "Online" : "Offline"}</div>
<div>{"Source: "}{props.entity ? "Internal" : "External"}</div>
{props.entity ? <div>{"Entity: "}<a href={`/admin/entities/${String(props.entity.id)}`}>{props.entity.name}</a></div> : ""}
{r?.heartbeatTime ? <div>{"Last heartbeat: "}{formatTime(r.heartbeatTime)}</div> : ""}
{r?.timezone ? <div>{"Timezone: "}{String(r.timezone)}</div> : ""}
</div>
</div>
<div style="display:flex; gap:0.5rem">
<a href="/admin/ablesign/screens" class="btn btn-sm btn-ghost">Back to Screens</a>
</div>
</Layout>
);
}
interface AbleSignContentPageProps { content: any[]; accounts: any[]; } interface AbleSignContentPageProps { content: any[]; accounts: any[]; }
export function AbleSignContentPage(props: AbleSignContentPageProps) { export function AbleSignContentPage(props: AbleSignContentPageProps) {

View file

@ -73,7 +73,6 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/os-updates" label="OS Updates" icon="&#9679;" active={a === "os-updates"} /> <NavItem href="/admin/os-updates" label="OS Updates" icon="&#9679;" active={a === "os-updates"} />
<NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="&#9729;" active={a === "cloud"} /> <NavItem href="/admin/cloud-accounts" label="Cloud Cams" icon="&#9729;" active={a === "cloud"} />
<NavGroup label="AbleSign" icon="&#9654;" active={a?.startsWith("ablesign")}> <NavGroup label="AbleSign" icon="&#9654;" 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/screens" label="Screens" icon=" " active={a === "ablesign-screens"} />
<NavItem href="/admin/ablesign/content" label="Content" icon=" " active={a === "ablesign-content"} /> <NavItem href="/admin/ablesign/content" label="Content" icon=" " active={a === "ablesign-content"} />
<NavItem href="/admin/ablesign/playlists" label="Playlists" icon=" " active={a === "ablesign-playlists"} /> <NavItem href="/admin/ablesign/playlists" label="Playlists" icon=" " active={a === "ablesign-playlists"} />