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 ? (
) : 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)}
+
- {props.attachedLayouts.length === 0 ? (
- No layouts attached yet.
- ) : (
-
-
-
-
- | Name |
- Priority |
- Default |
- |
-
-
-
- {props.attachedLayouts.map((l) => (
-
- | {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,
+ )}
+