From f40b730fe90d74c6a22da9f7a59340af60946ccd Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Wed, 13 May 2026 01:37:15 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20htmx=20audit=20=E2=80=94=20convert?= =?UTF-8?q?=20kiosk/display/label=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Action buttons that don't need a redirect now use hx-post: - Kiosk power (wake/standby), fan (auto/off/50%/full) - Kiosk switch-layout dropdown - Kiosk GPIO delete (row swap-out) - Camera labels add/remove (list re-render) - Kiosk labels add/remove (list re-render) - Display attach/detach layout (list re-render) Server routes return HTML fragments via isHtmxRequest() check, otherwise still 302 redirect for direct-URL access. Forms that legitimately redirect (create/edit/delete, auth flows) stay as standard form posts. --- .../service-admin-http/routes-admin.ts | 47 ++ server/src/web-templates/admin-pages.tsx | 453 ++++++++++++------ 2 files changed, 349 insertions(+), 151 deletions(-) diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index afe238f..8ac6595 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -28,6 +28,9 @@ import { NoderedEmbedPage, renderCell, renderGrid, + renderCameraLabels, + renderKioskLabels, + renderDisplayLayouts, } from "../../web-templates/admin-pages.js"; import { discover as onvifDiscover } from "../../shared/onvif.js"; import { generateBundle } from "../../shared/bundle.js"; @@ -959,6 +962,17 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } }); }); + // Render the attached + available layouts region for a display. + const renderDisplayLayoutsFragment = (displayId: number): Response => { + const display = deps.repo.getDisplayById(displayId); + const attached = deps.repo.listLayoutsForDisplay(displayId); + const attachedIds = new Set(attached.map((l) => l.id)); + const available = deps.repo.listLayouts().filter((l) => !attachedIds.has(l.id)); + return htmlFragment( + renderDisplayLayouts(displayId, display?.default_layout_id ?? null, attached, available), + ); + }; + // Attach a layout to a display. app.post("/admin/displays/:id/layouts", async (event) => { const displayId = Number(getRouterParam(event, "id")); @@ -968,6 +982,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { deps.repo.attachLayoutToDisplay(displayId, layoutId); notifyKiosks(); } + if (isHtmxRequest(event)) { + return renderDisplayLayoutsFragment(displayId); + } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); }); @@ -977,6 +994,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const layoutId = Number(getRouterParam(event, "layoutId")); deps.repo.detachLayoutFromDisplay(displayId, layoutId); notifyKiosks(); + if (isHtmxRequest(event)) { + return renderDisplayLayoutsFragment(displayId); + } return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } }); }); @@ -1090,6 +1110,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (labelId) { deps.repo.attachCameraLabel(camId, labelId); } + if (isHtmxRequest(event)) { + return htmlFragment(renderCameraLabels(camId, deps.repo.cameraLabelIds(camId), deps.repo.listLabels())); + } return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); }); @@ -1098,6 +1121,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const body = await readBody>(event); const labelId = Number(body?.["label_id"]); deps.repo.detachCameraLabel(camId, labelId); + if (isHtmxRequest(event)) { + return htmlFragment(renderCameraLabels(camId, deps.repo.cameraLabelIds(camId), deps.repo.listLabels())); + } return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } }); }); @@ -1167,6 +1193,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const bindingId = Number(getRouterParam(event, "bindingId")); deps.repo.deleteGpioBinding(bindingId); notifyKiosks(); + if (isHtmxRequest(event)) { + // Row is swapped via hx-target="closest tr" hx-swap="outerHTML" — empty + // response collapses the row out of the table. + return htmlFragment(""); + } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); @@ -1194,6 +1225,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (labelId) { deps.repo.attachKioskLabel(kioskId, labelId, role); } + if (isHtmxRequest(event)) { + const kioskLabels = deps.repo.listKioskLabels(kioskId).map((kl) => ({ + label_id: kl.label_id, + name: kl.name, + role: kl.role, + })); + return htmlFragment(renderKioskLabels(kioskId, kioskLabels, deps.repo.listLabels())); + } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); @@ -1202,6 +1241,14 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const body = await readBody>(event); const labelId = Number(body?.["label_id"]); deps.repo.detachKioskLabel(kioskId, labelId); + if (isHtmxRequest(event)) { + const kioskLabels = deps.repo.listKioskLabels(kioskId).map((kl) => ({ + label_id: kl.label_id, + name: kl.name, + role: kl.role, + })); + return htmlFragment(renderKioskLabels(kioskId, kioskLabels, deps.repo.listLabels())); + } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } }); }); diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index a075ca3..3418371 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1039,6 +1039,65 @@ interface CameraEditProps { success?: string; } +/** + * Render the camera labels region (chips + add forms). Returned standalone so + * htmx label add/remove can swap just this fragment via + * hx-target="#camera-labels-" hx-swap="innerHTML". + */ +export function renderCameraLabels( + cameraId: number, + labels: Array<{ label_id: number; name: string }>, + allLabels: Label[], +): string { + const labelsTargetSelector = `#camera-labels-${String(cameraId)}`; + return ( +
+ {labels.length > 0 ? ( +
+ {labels.map((l) => ( + + ))} +
+ ) : ( +

No labels attached

+ )} +
+ + +
+
+ + +
+
+ ); +} + export function CameraEditPage(props: CameraEditProps) { const cam = props.camera; return ( @@ -1124,32 +1183,9 @@ export function CameraEditPage(props: CameraEditProps) {

Labels

- {props.labels.length > 0 ? ( -
- {props.labels.map((l) => ( -
- - -
- ))} -
- ) : ( -

No labels attached

- )} -
- - -
-
- - -
+
+ {renderCameraLabels(cam.id, props.labels, props.allLabels)} +
@@ -1196,6 +1232,73 @@ interface KioskEditProps { success?: string; } +/** + * Render the kiosk labels region (chips + add forms). Returned standalone so + * htmx label add/remove can swap just this fragment via + * hx-target="#kiosk-labels-" hx-swap="innerHTML". + */ +export function renderKioskLabels( + kioskId: number, + labels: Array<{ label_id: number; name: string; role: string }>, + allLabels: Label[], +): string { + const labelsTargetSelector = `#kiosk-labels-${String(kioskId)}`; + return ( +
+ {labels.length > 0 ? ( +
+ {labels.map((l) => ( + + ))} +
+ ) : ( +

No labels attached

+ )} +
+ + + +
+
+ + + +
+
+ ); +} + export function KioskEditPage(props: KioskEditProps) { const k = props.kiosk; return ( @@ -1233,30 +1336,44 @@ export function KioskEditPage(props: KioskEditProps) {
Display Power
-
- -
-
- -
+ +
{props.switchableLayouts && props.switchableLayouts.length > 0 ? (
Switch Layout
-
- {props.switchableLayouts.map((l) => ( ))} - -
+ +
) : null} @@ -1268,22 +1385,42 @@ export function KioskEditPage(props: KioskEditProps) {
PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}
-
- - -
-
- - -
-
- - -
-
- - -
+ + + +
@@ -1343,9 +1480,16 @@ export function KioskEditPage(props: KioskEditProps) { {g.edge ?? "—"} {g.topic} -
- -
+ ))} @@ -1400,40 +1544,9 @@ export function KioskEditPage(props: KioskEditProps) {

Labels

- {props.labels.length > 0 ? ( -
- {props.labels.map((l) => ( -
- - -
- ))} -
- ) : ( -

No labels attached

- )} -
- - - -
-
- - - -
+
+ {renderKioskLabels(k.id, props.labels, props.allLabels)} +
@@ -2017,6 +2130,93 @@ interface DisplayEditPageProps { success?: string; } +/** + * Render the attached + available layouts region for a display. Returned + * standalone so htmx attach/detach can swap just this fragment via + * hx-target="#display-layouts-" hx-swap="innerHTML". + */ +export function renderDisplayLayouts( + displayId: number, + defaultLayoutId: number | null, + attached: LayoutType[], + available: LayoutType[], +): string { + const target = `#display-layouts-${String(displayId)}`; + return ( +
+ {attached.length === 0 ? ( +

No layouts attached yet.

+ ) : ( +
+ + + + + + + + + + + {attached.map((l) => ( + + + + + + + ))} + +
NamePriorityDefault
{l.name}{l.priority}{defaultLayoutId === l.id ? Yes : ""} + + +
+
+ )} + + {available.length > 0 ? ( + + + + + ) : ( +

+ {attached.length === 0 + ? No layouts exist yet. Create one. + : "All existing layouts are already attached."} +

+ )} +
+ ); +} + export function DisplayEditPage(props: DisplayEditPageProps) { const d = props.display; return ( @@ -2109,63 +2309,14 @@ export function DisplayEditPage(props: DisplayEditPageProps) { attached layouts in its bundle.

- {props.attachedLayouts.length === 0 ? ( -

No layouts attached yet.

- ) : ( -
- - - - - - - - - - - {props.attachedLayouts.map((l) => ( - - - - - - - ))} - -
NamePriorityDefault
{l.name}{l.priority}{d.default_layout_id === l.id ? Yes : ""} - -
- -
-
-
- )} - - {props.availableLayouts.length > 0 ? ( -
- - -
- ) : ( -

- {props.attachedLayouts.length === 0 - ? No layouts exist yet. Create one. - : "All existing layouts are already attached."} -

- )} +
+ {renderDisplayLayouts( + d.id, + d.default_layout_id ?? null, + props.attachedLayouts, + props.availableLayouts, + )} +