mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
refactor: htmx audit — convert kiosk/display/label actions
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.
This commit is contained in:
parent
766db445c4
commit
f40b730fe9
2 changed files with 349 additions and 151 deletions
|
|
@ -28,6 +28,9 @@ import {
|
||||||
NoderedEmbedPage,
|
NoderedEmbedPage,
|
||||||
renderCell,
|
renderCell,
|
||||||
renderGrid,
|
renderGrid,
|
||||||
|
renderCameraLabels,
|
||||||
|
renderKioskLabels,
|
||||||
|
renderDisplayLayouts,
|
||||||
} from "../../web-templates/admin-pages.js";
|
} from "../../web-templates/admin-pages.js";
|
||||||
import { discover as onvifDiscover } from "../../shared/onvif.js";
|
import { discover as onvifDiscover } from "../../shared/onvif.js";
|
||||||
import { generateBundle } from "../../shared/bundle.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}` } });
|
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.
|
// Attach a layout to a display.
|
||||||
app.post("/admin/displays/:id/layouts", async (event) => {
|
app.post("/admin/displays/:id/layouts", async (event) => {
|
||||||
const displayId = Number(getRouterParam(event, "id"));
|
const displayId = Number(getRouterParam(event, "id"));
|
||||||
|
|
@ -968,6 +982,9 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
deps.repo.attachLayoutToDisplay(displayId, layoutId);
|
deps.repo.attachLayoutToDisplay(displayId, layoutId);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
}
|
}
|
||||||
|
if (isHtmxRequest(event)) {
|
||||||
|
return renderDisplayLayoutsFragment(displayId);
|
||||||
|
}
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/displays/${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"));
|
const layoutId = Number(getRouterParam(event, "layoutId"));
|
||||||
deps.repo.detachLayoutFromDisplay(displayId, layoutId);
|
deps.repo.detachLayoutFromDisplay(displayId, layoutId);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
|
if (isHtmxRequest(event)) {
|
||||||
|
return renderDisplayLayoutsFragment(displayId);
|
||||||
|
}
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/displays/${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) {
|
if (labelId) {
|
||||||
deps.repo.attachCameraLabel(camId, 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}` } });
|
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<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const labelId = Number(body?.["label_id"]);
|
const labelId = Number(body?.["label_id"]);
|
||||||
deps.repo.detachCameraLabel(camId, labelId);
|
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}` } });
|
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"));
|
const bindingId = Number(getRouterParam(event, "bindingId"));
|
||||||
deps.repo.deleteGpioBinding(bindingId);
|
deps.repo.deleteGpioBinding(bindingId);
|
||||||
notifyKiosks();
|
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}` } });
|
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) {
|
if (labelId) {
|
||||||
deps.repo.attachKioskLabel(kioskId, labelId, role);
|
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}` } });
|
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<Record<string, string>>(event);
|
const body = await readBody<Record<string, string>>(event);
|
||||||
const labelId = Number(body?.["label_id"]);
|
const labelId = Number(body?.["label_id"]);
|
||||||
deps.repo.detachKioskLabel(kioskId, labelId);
|
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}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1039,6 +1039,65 @@ interface CameraEditProps {
|
||||||
success?: string;
|
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-<id>" 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 (
|
||||||
|
<div>
|
||||||
|
{labels.length > 0 ? (
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1rem">
|
||||||
|
{labels.map((l) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="badge badge-blue"
|
||||||
|
style="cursor:pointer; border:none"
|
||||||
|
title="Click to remove"
|
||||||
|
hx-post={`/admin/cameras/${String(cameraId)}/labels/remove`}
|
||||||
|
hx-vals={JSON.stringify({ label_id: l.label_id })}
|
||||||
|
hx-target={labelsTargetSelector}
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
{l.name} ×
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style="color:#999; margin-bottom:1rem">No labels attached</p>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
hx-post={`/admin/cameras/${String(cameraId)}/labels`}
|
||||||
|
hx-target={labelsTargetSelector}
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="display:flex; gap:0.5rem"
|
||||||
|
>
|
||||||
|
<select name="label_id" class="form-input" style="flex:1">
|
||||||
|
{allLabels
|
||||||
|
.filter((al) => !labels.some((l) => l.label_id === al.id))
|
||||||
|
.map((al) => <option value={String(al.id)}>{al.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
hx-post={`/admin/cameras/${String(cameraId)}/labels`}
|
||||||
|
hx-target={labelsTargetSelector}
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="display:flex; gap:0.5rem; margin-top:0.5rem"
|
||||||
|
>
|
||||||
|
<input name="new_label" type="text" class="form-input" placeholder="Or create new label..." style="flex:1" />
|
||||||
|
<button type="submit" class="btn btn-ghost">Create & Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CameraEditPage(props: CameraEditProps) {
|
export function CameraEditPage(props: CameraEditProps) {
|
||||||
const cam = props.camera;
|
const cam = props.camera;
|
||||||
return (
|
return (
|
||||||
|
|
@ -1124,32 +1183,9 @@ export function CameraEditPage(props: CameraEditProps) {
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
||||||
{props.labels.length > 0 ? (
|
<div id={`camera-labels-${String(cam.id)}`}>
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1rem">
|
{renderCameraLabels(cam.id, props.labels, props.allLabels)}
|
||||||
{props.labels.map((l) => (
|
</div>
|
||||||
<form method="post" action={`/admin/cameras/${cam.id}/labels/remove`} style="display:inline">
|
|
||||||
<input type="hidden" name="label_id" value={String(l.label_id)} />
|
|
||||||
<button type="submit" class="badge badge-blue" style="cursor:pointer; border:none" title="Click to remove">
|
|
||||||
{l.name} ×
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style="color:#999; margin-bottom:1rem">No labels attached</p>
|
|
||||||
)}
|
|
||||||
<form method="post" action={`/admin/cameras/${cam.id}/labels`} style="display:flex; gap:0.5rem">
|
|
||||||
<select name="label_id" class="form-input" style="flex:1">
|
|
||||||
{props.allLabels
|
|
||||||
.filter((al) => !props.labels.some((l) => l.label_id === al.id))
|
|
||||||
.map((al) => <option value={String(al.id)}>{al.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action={`/admin/cameras/${cam.id}/labels`} style="display:flex; gap:0.5rem; margin-top:0.5rem">
|
|
||||||
<input name="new_label" type="text" class="form-input" placeholder="Or create new label..." style="flex:1" />
|
|
||||||
<button type="submit" class="btn btn-ghost">Create & Add</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
|
@ -1196,6 +1232,73 @@ interface KioskEditProps {
|
||||||
success?: string;
|
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-<id>" 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 (
|
||||||
|
<div>
|
||||||
|
{labels.length > 0 ? (
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1rem">
|
||||||
|
{labels.map((l) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="badge badge-blue"
|
||||||
|
style="cursor:pointer; border:none"
|
||||||
|
title="Click to remove"
|
||||||
|
hx-post={`/admin/kiosks/${String(kioskId)}/labels/remove`}
|
||||||
|
hx-vals={JSON.stringify({ label_id: l.label_id })}
|
||||||
|
hx-target={labelsTargetSelector}
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
{l.name} ({l.role}) ×
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style="color:#999; margin-bottom:1rem">No labels attached</p>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
hx-post={`/admin/kiosks/${String(kioskId)}/labels`}
|
||||||
|
hx-target={labelsTargetSelector}
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="display:flex; gap:0.5rem"
|
||||||
|
>
|
||||||
|
<select name="label_id" class="form-input" style="flex:1">
|
||||||
|
{allLabels
|
||||||
|
.filter((al) => !labels.some((l) => l.label_id === al.id))
|
||||||
|
.map((al) => <option value={String(al.id)}>{al.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select name="role" class="form-input" style="width:120px">
|
||||||
|
<option value="consume">consume</option>
|
||||||
|
<option value="operate">operate</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
hx-post={`/admin/kiosks/${String(kioskId)}/labels`}
|
||||||
|
hx-target={labelsTargetSelector}
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="display:flex; gap:0.5rem; margin-top:0.5rem"
|
||||||
|
>
|
||||||
|
<input name="new_label" type="text" class="form-input" placeholder="Or create new label..." style="flex:1" />
|
||||||
|
<select name="role" class="form-input" style="width:120px">
|
||||||
|
<option value="consume">consume</option>
|
||||||
|
<option value="operate">operate</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-ghost">Create & Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function KioskEditPage(props: KioskEditProps) {
|
export function KioskEditPage(props: KioskEditProps) {
|
||||||
const k = props.kiosk;
|
const k = props.kiosk;
|
||||||
return (
|
return (
|
||||||
|
|
@ -1233,30 +1336,44 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
||||||
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Display Power</div>
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Display Power</div>
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/power/wake`} style="display:inline">
|
<button
|
||||||
<button type="submit" class="btn btn-sm">Wake</button>
|
type="button"
|
||||||
</form>
|
class="btn btn-sm"
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/power/standby`} style="display:inline; margin-left:0.5rem">
|
{...{
|
||||||
<button type="submit" class="btn btn-sm btn-ghost">Standby</button>
|
"hx-post": `/admin/kiosks/${String(k.id)}/power/wake`,
|
||||||
</form>
|
"hx-swap": "none",
|
||||||
|
}}
|
||||||
|
>Wake</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
style="margin-left:0.5rem"
|
||||||
|
{...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/power/standby`,
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}
|
||||||
|
>Standby</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.switchableLayouts && props.switchableLayouts.length > 0 ? (
|
{props.switchableLayouts && props.switchableLayouts.length > 0 ? (
|
||||||
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
||||||
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout</div>
|
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout</div>
|
||||||
<form
|
<div style="display:flex; gap:0.5rem; align-items:center">
|
||||||
method="post"
|
<select id={`kiosk-layout-pick-${String(k.id)}`} class="form-input" style="flex:1">
|
||||||
action={`/admin/kiosks/${k.id}/layout/0`}
|
|
||||||
style="display:flex; gap:0.5rem; align-items:center"
|
|
||||||
{...{ "onsubmit": "this.action = this.action.replace(/\\/layout\\/.*/, '/layout/' + this.layout_id.value); return true;" }}
|
|
||||||
>
|
|
||||||
<select name="layout_id" class="form-input" style="flex:1">
|
|
||||||
{props.switchableLayouts.map((l) => (
|
{props.switchableLayouts.map((l) => (
|
||||||
<option value={String(l.id)}>{l.name}</option>
|
<option value={String(l.id)}>{l.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-sm">Switch</button>
|
<button
|
||||||
</form>
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
{...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/layout/0`,
|
||||||
|
"hx-swap": "none",
|
||||||
|
"hx-on::config-request": `event.detail.path = event.detail.path.replace(/\\/layout\\/.*/, '/layout/' + document.getElementById('kiosk-layout-pick-${String(k.id)}').value);`,
|
||||||
|
}}
|
||||||
|
>Switch</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -1268,22 +1385,42 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
<div>PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}</div>
|
<div>PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/fan`} style="display:inline">
|
<button
|
||||||
<input type="hidden" name="mode" value="auto" />
|
type="button"
|
||||||
<button type="submit" class="btn btn-sm btn-ghost">Auto</button>
|
class="btn btn-sm btn-ghost"
|
||||||
</form>
|
{...{
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/fan`} style="display:inline">
|
"hx-post": `/admin/kiosks/${String(k.id)}/fan`,
|
||||||
<input type="hidden" name="pwm" value="0" />
|
"hx-vals": JSON.stringify({ mode: "auto" }),
|
||||||
<button type="submit" class="btn btn-sm btn-ghost">Off</button>
|
"hx-swap": "none",
|
||||||
</form>
|
}}
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/fan`} style="display:inline">
|
>Auto</button>
|
||||||
<input type="hidden" name="pwm" value="128" />
|
<button
|
||||||
<button type="submit" class="btn btn-sm btn-ghost">50%</button>
|
type="button"
|
||||||
</form>
|
class="btn btn-sm btn-ghost"
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/fan`} style="display:inline">
|
{...{
|
||||||
<input type="hidden" name="pwm" value="255" />
|
"hx-post": `/admin/kiosks/${String(k.id)}/fan`,
|
||||||
<button type="submit" class="btn btn-sm btn-ghost">Full</button>
|
"hx-vals": JSON.stringify({ pwm: "0" }),
|
||||||
</form>
|
"hx-swap": "none",
|
||||||
|
}}
|
||||||
|
>Off</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
{...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/fan`,
|
||||||
|
"hx-vals": JSON.stringify({ pwm: "128" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}
|
||||||
|
>50%</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
{...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/fan`,
|
||||||
|
"hx-vals": JSON.stringify({ pwm: "255" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}
|
||||||
|
>Full</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1343,9 +1480,16 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
<td style="font-size:0.85rem">{g.edge ?? "—"}</td>
|
<td style="font-size:0.85rem">{g.edge ?? "—"}</td>
|
||||||
<td style="font-family:monospace; font-size:0.85rem">{g.topic}</td>
|
<td style="font-family:monospace; font-size:0.85rem">{g.topic}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/gpio/${g.id}/delete`} style="display:inline">
|
<button
|
||||||
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Remove GPIO binding?')"}}>×</button>
|
type="button"
|
||||||
</form>
|
class="btn btn-sm btn-danger"
|
||||||
|
{...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/gpio/${String(g.id)}/delete`,
|
||||||
|
"hx-target": "closest tr",
|
||||||
|
"hx-swap": "outerHTML",
|
||||||
|
"hx-confirm": "Remove GPIO binding?",
|
||||||
|
}}
|
||||||
|
>×</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1400,40 +1544,9 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
||||||
{props.labels.length > 0 ? (
|
<div id={`kiosk-labels-${String(k.id)}`}>
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1rem">
|
{renderKioskLabels(k.id, props.labels, props.allLabels)}
|
||||||
{props.labels.map((l) => (
|
</div>
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/labels/remove`} style="display:inline">
|
|
||||||
<input type="hidden" name="label_id" value={String(l.label_id)} />
|
|
||||||
<button type="submit" class="badge badge-blue" style="cursor:pointer; border:none" title="Click to remove">
|
|
||||||
{l.name} ({l.role}) ×
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style="color:#999; margin-bottom:1rem">No labels attached</p>
|
|
||||||
)}
|
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/labels`} style="display:flex; gap:0.5rem">
|
|
||||||
<select name="label_id" class="form-input" style="flex:1">
|
|
||||||
{props.allLabels
|
|
||||||
.filter((al) => !props.labels.some((l) => l.label_id === al.id))
|
|
||||||
.map((al) => <option value={String(al.id)}>{al.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<select name="role" class="form-input" style="width:120px">
|
|
||||||
<option value="consume">consume</option>
|
|
||||||
<option value="operate">operate</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/labels`} style="display:flex; gap:0.5rem; margin-top:0.5rem">
|
|
||||||
<input name="new_label" type="text" class="form-input" placeholder="Or create new label..." style="flex:1" />
|
|
||||||
<select name="role" class="form-input" style="width:120px">
|
|
||||||
<option value="consume">consume</option>
|
|
||||||
<option value="operate">operate</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn btn-ghost">Create & Add</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
|
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
|
||||||
|
|
@ -2017,6 +2130,93 @@ interface DisplayEditPageProps {
|
||||||
success?: string;
|
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-<id>" hx-swap="innerHTML".
|
||||||
|
*/
|
||||||
|
export function renderDisplayLayouts(
|
||||||
|
displayId: number,
|
||||||
|
defaultLayoutId: number | null,
|
||||||
|
attached: LayoutType[],
|
||||||
|
available: LayoutType[],
|
||||||
|
): string {
|
||||||
|
const target = `#display-layouts-${String(displayId)}`;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{attached.length === 0 ? (
|
||||||
|
<p style="color:#999; margin-bottom:1rem">No layouts attached yet.</p>
|
||||||
|
) : (
|
||||||
|
<div class="table-wrap" style="margin-bottom:1rem">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Default</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attached.map((l) => (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/admin/layouts/${String(l.id)}`}><strong>{l.name}</strong></a></td>
|
||||||
|
<td><span class={`badge ${l.priority === "hot" ? "badge-red" : l.priority === "cold" ? "badge-blue" : "badge-gray"}`}>{l.priority}</span></td>
|
||||||
|
<td>{defaultLayoutId === l.id ? <span class="badge badge-green">Yes</span> : ""}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
style="margin-right:0.25rem"
|
||||||
|
{...{
|
||||||
|
"hx-post": `/admin/displays/${String(displayId)}/layout/${String(l.id)}`,
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}
|
||||||
|
>Show</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-danger"
|
||||||
|
{...{
|
||||||
|
"hx-post": `/admin/displays/${String(displayId)}/layouts/${String(l.id)}/remove`,
|
||||||
|
"hx-target": target,
|
||||||
|
"hx-swap": "innerHTML",
|
||||||
|
"hx-confirm": "Detach this layout from the display?",
|
||||||
|
}}
|
||||||
|
>Detach</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{available.length > 0 ? (
|
||||||
|
<form
|
||||||
|
hx-post={`/admin/displays/${String(displayId)}/layouts`}
|
||||||
|
hx-target={target}
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
style="display:flex; gap:0.5rem"
|
||||||
|
>
|
||||||
|
<select name="layout_id" class="form-input" style="flex:1" required>
|
||||||
|
<option value="">-- Pick a layout to attach --</option>
|
||||||
|
{available.map((l) => (
|
||||||
|
<option value={String(l.id)}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Attach</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p style="color:#999; font-size:0.85rem; margin:0">
|
||||||
|
{attached.length === 0
|
||||||
|
? <span>No layouts exist yet. <a href="/admin/layouts/new">Create one</a>.</span>
|
||||||
|
: "All existing layouts are already attached."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DisplayEditPage(props: DisplayEditPageProps) {
|
export function DisplayEditPage(props: DisplayEditPageProps) {
|
||||||
const d = props.display;
|
const d = props.display;
|
||||||
return (
|
return (
|
||||||
|
|
@ -2109,63 +2309,14 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
|
||||||
attached layouts in its bundle.
|
attached layouts in its bundle.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{props.attachedLayouts.length === 0 ? (
|
<div id={`display-layouts-${String(d.id)}`}>
|
||||||
<p style="color:#999; margin-bottom:1rem">No layouts attached yet.</p>
|
{renderDisplayLayouts(
|
||||||
) : (
|
d.id,
|
||||||
<div class="table-wrap" style="margin-bottom:1rem">
|
d.default_layout_id ?? null,
|
||||||
<table>
|
props.attachedLayouts,
|
||||||
<thead>
|
props.availableLayouts,
|
||||||
<tr>
|
)}
|
||||||
<th>Name</th>
|
</div>
|
||||||
<th>Priority</th>
|
|
||||||
<th>Default</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{props.attachedLayouts.map((l) => (
|
|
||||||
<tr>
|
|
||||||
<td><a href={`/admin/layouts/${l.id}`}><strong>{l.name}</strong></a></td>
|
|
||||||
<td><span class={`badge ${l.priority === "hot" ? "badge-red" : l.priority === "cold" ? "badge-blue" : "badge-gray"}`}>{l.priority}</span></td>
|
|
||||||
<td>{d.default_layout_id === l.id ? <span class="badge badge-green">Yes</span> : ""}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
style="margin-right:0.25rem"
|
|
||||||
{...{
|
|
||||||
"hx-post": `/admin/displays/${d.id}/layout/${l.id}`,
|
|
||||||
"hx-swap": "none",
|
|
||||||
}}
|
|
||||||
>Show</button>
|
|
||||||
<form method="post" action={`/admin/displays/${d.id}/layouts/${l.id}/remove`} style="display:inline">
|
|
||||||
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Detach this layout from the display?')"}}>Detach</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{props.availableLayouts.length > 0 ? (
|
|
||||||
<form method="post" action={`/admin/displays/${d.id}/layouts`} style="display:flex; gap:0.5rem">
|
|
||||||
<select name="layout_id" class="form-input" style="flex:1" required>
|
|
||||||
<option value="">-- Pick a layout to attach --</option>
|
|
||||||
{props.availableLayouts.map((l) => (
|
|
||||||
<option value={String(l.id)}>{l.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="btn btn-primary">Attach</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<p style="color:#999; font-size:0.85rem; margin:0">
|
|
||||||
{props.attachedLayouts.length === 0
|
|
||||||
? <span>No layouts exist yet. <a href="/admin/layouts/new">Create one</a>.</span>
|
|
||||||
: "All existing layouts are already attached."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue