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:
Mitchell R 2026-05-13 01:37:15 +02:00
parent 766db445c4
commit f40b730fe9
2 changed files with 349 additions and 151 deletions

View file

@ -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}` } });
}); });

View file

@ -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 &amp; 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) => (
<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> </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 &amp; 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 &amp; 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) => (
<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> </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 &amp; 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>
<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> </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>