From 722ddcfb1289b92f00d49f612ac90859962b5c89 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sun, 10 May 2026 22:31:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20layout=20builder=20=E2=80=94=20resize?= =?UTF-8?q?=20cells=20+=20in-place=20htmx=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI improvements: - Click cell → htmx swaps in edit form inside the cell (no page reload) - Cancel re-fetches cell in read mode - Save returns updated cell HTML, htmx swaps it - Edit form includes Width/Height inputs for col_span/row_span - Inline +W/-W/+H/-H buttons on each cell for quick resize - Add (+) and delete (×) buttons also htmx — only the grid swaps Routes: - GET /admin/layouts/:id/cells/:cellId — cell fragment (read mode) - GET /admin/layouts/:id/cells/:cellId/edit — cell fragment (edit mode) - POST /admin/layouts/:id/cells/:cellId/resize — adjust span by delta - All cell ops return fragment if hx-request header present, else 302 All mutations trigger notifyKiosks() — kiosks live-update via WS. --- .../service-admin-http/routes-admin.ts | 112 ++++- .../src/plugins/service-store/repository.ts | 5 + server/src/web-templates/admin-pages.tsx | 453 +++++++++++------- 3 files changed, 394 insertions(+), 176 deletions(-) diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 079a40a..09dbfe1 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1,7 +1,7 @@ /** * Admin page routes — overview, cameras, kiosks, labels, etc. */ -import { type H3, readBody, getRouterParam, getQuery } from "h3"; +import { type H3, readBody, getRouterParam, getRequestHeader } from "h3"; import { htmlPage } from "./html-response.js"; import type { AdminDeps } from "./index.js"; import { confirmPairing } from "../../shared/pairing.js"; @@ -19,8 +19,20 @@ import { LayoutEditPage, DisplaysPage, DisplayEditPage, + renderCell, + renderGrid, } from "../../web-templates/admin-pages.js"; +function htmlFragment(markup: unknown): Response { + return new Response(String(markup), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} + +function isHtmxRequest(event: Parameters[0]): boolean { + return getRequestHeader(event, "hx-request") === "true"; +} + function notifyKiosks(): void { try { getCoordinator().notifyBundleChanged(); } catch { /* ignore */ } } @@ -255,16 +267,12 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const cells = deps.repo.layoutCells(id); const cameras = deps.repo.listCameras(); const displays = deps.repo.listDisplaysForLayout(id); - const q = getQuery(event) as Record; - const selectedRaw = q["cell"]; - const selectedCellId = selectedRaw ? Number(selectedRaw) : null; return htmlPage(LayoutEditPage({ user: user.username, layout, displays, cells, cameras, - selectedCellId: selectedCellId && cells.some((c) => c.id === selectedCellId) ? selectedCellId : null, })); }); @@ -287,7 +295,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // Create a new 1x1 cell. Two body shapes: // { position: { row, col } } — explicit position, may shift others. // { after_cell_id, direction } — relative to existing cell (right/below/left/above). - // Returns 302 redirect to the layout edit page (htmx will swap on hx-target). + // For htmx requests (hx-request header), returns the grid fragment; otherwise + // returns a 302 to the layout edit page. app.post("/admin/layouts/:id/cells", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const body = await readBody>(event); @@ -303,6 +312,10 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const cells = deps.repo.layoutCells(layoutId); const ref = cells.find((c) => c.id === afterId); if (!ref) { + if (isHtmxRequest(event)) { + const cameras = deps.repo.listCameras(); + return htmlFragment(renderGrid(layoutId, cells, cameras)); + } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); } if (direction === "right") { @@ -351,17 +364,48 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); notifyKiosks(); + if (isHtmxRequest(event)) { + const cells = deps.repo.layoutCells(layoutId); + const cameras = deps.repo.listCameras(); + return htmlFragment(renderGrid(layoutId, cells, cameras)); + } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); - // Update a cell's content assignment. + // GET a single cell in read mode (used by htmx Cancel button in inline edit). + app.get("/admin/layouts/:id/cells/:cellId", (event) => { + const layoutId = Number(getRouterParam(event, "id")); + const cellId = Number(getRouterParam(event, "cellId")); + const cell = deps.repo.getLayoutCellById(cellId); + if (!cell || cell.layout_id !== layoutId) { + return new Response("Not Found", { status: 404 }); + } + const cameras = deps.repo.listCameras(); + return htmlFragment(renderCell(layoutId, cell, cameras, "read")); + }); + + // GET a single cell in edit mode (htmx swap target for cell click). + app.get("/admin/layouts/:id/cells/:cellId/edit", (event) => { + const layoutId = Number(getRouterParam(event, "id")); + const cellId = Number(getRouterParam(event, "cellId")); + const cell = deps.repo.getLayoutCellById(cellId); + if (!cell || cell.layout_id !== layoutId) { + return new Response("Not Found", { status: 404 }); + } + const cameras = deps.repo.listCameras(); + return htmlFragment(renderCell(layoutId, cell, cameras, "edit")); + }); + + // Update a cell's content assignment + dimensions. + // For htmx requests, returns the updated cell HTML (read mode) for outerHTML + // swap onto the cell element. For normal POSTs, returns 302. app.post("/admin/layouts/:id/cells/:cellId", async (event) => { const layoutId = Number(getRouterParam(event, "id")); const cellId = Number(getRouterParam(event, "cellId")); const body = await readBody>(event); const contentType = (body?.["content_type"] ?? "html") as "camera" | "web" | "html"; - deps.repo.updateLayoutCell(cellId, { + const patch: Record = { content_type: contentType, camera_id: contentType === "camera" && body?.["camera_id"] ? Number(body["camera_id"]) : null, stream_selector: contentType === "camera" @@ -369,9 +413,54 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { : "auto", web_url: contentType === "web" ? (body?.["web_url"] ?? null) : null, html_content: contentType === "html" ? (body?.["html_content"] ?? null) : null, - }); + }; + + const colSpanRaw = body?.["col_span"]; + const rowSpanRaw = body?.["row_span"]; + if (colSpanRaw != null && String(colSpanRaw).trim() !== "") { + const v = Math.max(1, Number(colSpanRaw) || 1); + patch["col_span"] = v; + } + if (rowSpanRaw != null && String(rowSpanRaw).trim() !== "") { + const v = Math.max(1, Number(rowSpanRaw) || 1); + patch["row_span"] = v; + } + + deps.repo.updateLayoutCell(cellId, patch as any); notifyKiosks(); + if (isHtmxRequest(event)) { + const cell = deps.repo.getLayoutCellById(cellId); + if (!cell) return new Response("", { headers: { "content-type": "text/html; charset=utf-8" } }); + const cameras = deps.repo.listCameras(); + return htmlFragment(renderCell(layoutId, cell, cameras, "read")); + } + return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); + }); + + // Resize a cell by ±1 on row_span or col_span. Returns the grid fragment. + app.post("/admin/layouts/:id/cells/:cellId/resize", async (event) => { + const layoutId = Number(getRouterParam(event, "id")); + const cellId = Number(getRouterParam(event, "cellId")); + const body = await readBody>(event); + const dim = String(body?.["dim"] ?? ""); + const delta = Number(body?.["delta"] ?? 0) || 0; + + const cell = deps.repo.getLayoutCellById(cellId); + if (cell && cell.layout_id === layoutId && (dim === "row_span" || dim === "col_span") && delta !== 0) { + const current = dim === "row_span" ? cell.row_span : cell.col_span; + const next = Math.max(1, current + delta); + if (next !== current) { + deps.repo.updateLayoutCell(cellId, { [dim]: next } as any); + notifyKiosks(); + } + } + + const cells = deps.repo.layoutCells(layoutId); + const cameras = deps.repo.listCameras(); + if (isHtmxRequest(event)) { + return htmlFragment(renderGrid(layoutId, cells, cameras)); + } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); @@ -380,6 +469,11 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const cellId = Number(getRouterParam(event, "cellId")); deps.repo.deleteLayoutCell(cellId); notifyKiosks(); + if (isHtmxRequest(event)) { + const cells = deps.repo.layoutCells(layoutId); + const cameras = deps.repo.listCameras(); + return htmlFragment(renderGrid(layoutId, cells, cameras)); + } return new Response(null, { status: 302, headers: { location: `/admin/layouts/${layoutId}` } }); }); diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index b254510..3cea510 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -630,6 +630,11 @@ export class Repository { return rs.map((r) => rowToLayoutCell(r as Record)); } + getLayoutCellById(id: number): LayoutCell | null { + const r = this.prep("SELECT * FROM layout_cells WHERE id = ?").get(id); + return r ? rowToLayoutCell(r as Record) : null; + } + // =========================================================================== // display-chain bundle queries (kiosk → display → layouts → cells → cameras) // =========================================================================== diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 8702449..3664d7b 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1010,11 +1010,11 @@ interface LayoutEditPageProps { success?: string; } -const LAYOUT_BUILDER_CSS = ` +export const LAYOUT_BUILDER_CSS = ` .layout-builder { display: grid; gap: 4px; aspect-ratio: 16/9; max-width: 100%; background: #ddd; padding: 4px; border-radius: 4px; } .layout-cell { background: #fff; border: 2px solid #2563eb; border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; min-height: 60px; padding: 4px; text-align: center; font-size: 0.85rem; font-weight: 600; color: #1e40af; overflow: hidden; } .layout-cell:hover { background: #f0f7ff; } -.layout-cell.selected { background: #dbeafe; border-color: #1e40af; box-shadow: 0 0 0 2px #1e40af33; } +.layout-cell.editing { background: #f9fafb; border-color: #1e40af; box-shadow: 0 0 0 2px #1e40af33; cursor: default; align-items: stretch; justify-content: stretch; text-align: left; font-weight: 400; color: inherit; padding: 8px; overflow: auto; } .layout-cell-empty-text { color: #999; font-size: 0.75rem; font-weight: 400; } .layout-cell-add { position: absolute; background: #2563eb; color: #fff; border: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1; opacity: 0; transition: opacity 0.2s; padding: 0; z-index: 2; } .layout-cell:hover .layout-cell-add { opacity: 1; } @@ -1025,20 +1025,271 @@ const LAYOUT_BUILDER_CSS = ` .layout-cell-add-left { left: -12px; top: 50%; transform: translateY(-50%); } .layout-cell-delete { position: absolute; top: 4px; right: 4px; background: rgba(220, 38, 38, 0.9); color: #fff; border: none; width: 20px; height: 20px; border-radius: 50%; cursor: pointer; font-size: 12px; line-height: 1; padding: 0; opacity: 0; transition: opacity 0.2s; z-index: 2; } .layout-cell:hover .layout-cell-delete { opacity: 1; } +.layout-cell-resize { position: absolute; bottom: 4px; right: 4px; display: flex; gap: 2px; opacity: 0; transition: opacity 0.2s; z-index: 2; } +.layout-cell:hover .layout-cell-resize { opacity: 1; } +.layout-cell-resize button { background: rgba(37, 99, 235, 0.85); color: #fff; border: none; min-width: 24px; height: 18px; border-radius: 3px; cursor: pointer; font-size: 0.65rem; line-height: 1; padding: 0 4px; font-weight: 700; } +.layout-cell-resize button:hover { background: #1e40af; } +.layout-cell-edit-form { width: 100%; } +.layout-cell-edit-form .form-group { margin-bottom: 0.5rem; } +.layout-cell-edit-form label { font-size: 0.75rem; font-weight: 600; display: block; margin-bottom: 0.15rem; } +.layout-cell-edit-form .form-input, .layout-cell-edit-form input, .layout-cell-edit-form select, .layout-cell-edit-form textarea { font-size: 0.8rem; padding: 0.25rem 0.4rem; width: 100%; box-sizing: border-box; } +.layout-cell-edit-form .btn { font-size: 0.75rem; padding: 0.25rem 0.6rem; } +.layout-cell-edit-form-actions { display: flex; gap: 0.35rem; margin-top: 0.5rem; flex-wrap: wrap; } +.layout-cell-edit-form .span-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; } .layout-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/9; background: #f3f4f6; border-radius: 4px; } .layout-empty-add { background: #2563eb; color: #fff; border: none; width: 80px; height: 80px; border-radius: 50%; cursor: pointer; font-size: 36px; line-height: 1; padding: 0; } .layout-empty-add:hover { background: #1e40af; } `; -export function LayoutEditPage(props: LayoutEditPageProps) { - const l = props.layout; - const cells = props.cells; +function cellLabel(c: LayoutCell, cameraById: Map): string { + if (c.content_type === "camera" && c.camera_id) { + return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`; + } + if (c.content_type === "web") return c.web_url ? `Web: ${c.web_url}` : "Web"; + if (c.content_type === "html") return c.html_content ? "HTML" : "HTML (empty)"; + return "Empty"; +} + +function cellGridStyle(c: LayoutCell): string { + return `grid-column:${String(c.col + 1)} / span ${String(c.col_span)}; grid-row:${String(c.row + 1)} / span ${String(c.row_span)};`; +} + +/** + * Render a single cell, either in read-only display mode or edit mode (form + * inline inside the cell). Returns a `
` element + * suitable for hx-swap="outerHTML" against itself. + */ +export function renderCell( + layoutId: number, + c: LayoutCell, + cameras: Camera[], + mode: "read" | "edit", +): string { const cameraById = new Map(); - for (const cam of props.cameras) { - cameraById.set(cam.id, cam); + for (const cam of cameras) cameraById.set(cam.id, cam); + const style = cellGridStyle(c); + const cellGetUrl = `/admin/layouts/${String(layoutId)}/cells/${String(c.id)}`; + const cellEditUrl = `${cellGetUrl}/edit`; + const addUrl = `/admin/layouts/${String(layoutId)}/cells`; + const deleteUrl = `${cellGetUrl}/delete`; + const resizeUrl = `${cellGetUrl}/resize`; + + if (mode === "edit") { + return ( +
+
+
+ +
+ + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+ +
+ ); } - // Compute grid dimensions from cells. + // Read mode. + const isEmpty = (c.content_type === "html" && !c.html_content) + || (c.content_type === "camera" && !c.camera_id) + || (c.content_type === "web" && !c.web_url); + const label = cellLabel(c, cameraById); + + return ( +
+ {isEmpty + ? {label} + : {label}} + + {/* + buttons (4 sides) — htmx posts to add endpoint, swaps grid */} + {(["top", "right", "bottom", "left"] as const).map((dir) => { + const directionParam = dir === "top" ? "above" : dir; + return ( + + ); + })} + + {/* resize buttons */} +
+ + + + +
+ + {/* delete button */} + +
+ ); +} + +/** + * Render the inner content of the `#layout-grid` container — either the empty + * placeholder or the full builder grid with all cells. This is what htmx swaps + * in via `hx-target="#layout-grid" hx-swap="innerHTML"` after add/delete/resize. + */ +export function renderGrid( + layoutId: number, + cells: LayoutCell[], + cameras: Camera[], +): string { + if (cells.length === 0) { + return ( +
+ +
+ ); + } + + // Compute grid dimensions. let gridCols = 1; let gridRows = 1; for (const c of cells) { @@ -1048,17 +1299,28 @@ export function LayoutEditPage(props: LayoutEditPageProps) { if (bottom > gridRows) gridRows = bottom; } - const selectedCell = props.selectedCellId - ? cells.find((c) => c.id === props.selectedCellId) ?? null - : null; + return ( +
+ {cells.map((c) => renderCell(layoutId, c, cameras, "read"))} +
+ ); +} - function cellLabel(c: LayoutCell): string { - if (c.content_type === "camera" && c.camera_id) { - return cameraById.get(c.camera_id)?.name ?? `cam #${String(c.camera_id)}`; - } - if (c.content_type === "web") return c.web_url ? `Web: ${c.web_url}` : "Web"; - if (c.content_type === "html") return c.html_content ? "HTML" : "HTML (empty)"; - return "Empty"; +export function LayoutEditPage(props: LayoutEditPageProps) { + const l = props.layout; + const cells = props.cells; + + // Compute grid dimensions from cells (for summary text). + let gridCols = 1; + let gridRows = 1; + for (const c of cells) { + const right = c.col + c.col_span; + const bottom = c.row + c.row_span; + if (right > gridCols) gridCols = right; + if (bottom > gridRows) gridRows = bottom; } return ( @@ -1132,157 +1394,14 @@ export function LayoutEditPage(props: LayoutEditPageProps) {

Layout Builder

- Hover a cell to see + buttons (add a neighbour) and the × delete button. - Click a cell to assign content. + Hover a cell for + (add neighbour), resize handles + (+W -W +H -H) and × (delete). + Click a cell to edit content in-place.

- {cells.length === 0 ? ( -
-
- - - -
-
- ) : ( -
- {cells.map((c) => { - const isSelected = selectedCell?.id === c.id; - const cellStyle = `grid-column:${String(c.col + 1)} / span ${String(c.col_span)}; grid-row:${String(c.row + 1)} / span ${String(c.row_span)};`; - const isEmpty = c.content_type === "html" && !c.html_content - || c.content_type === "camera" && !c.camera_id - || c.content_type === "web" && !c.web_url; - return ( - - {isEmpty - ? {cellLabel(c)} - : {cellLabel(c)} - } - {/* + buttons (4 sides) */} - {(["top", "right", "bottom", "left"] as const).map((dir) => { - const directionParam = dir === "top" ? "above" : dir; - return ( -
- - - -
- ); - })} - {/* delete button */} -
- -
-
- ); - })} -
- )} -
- - {/* Selected-cell content form */} - {selectedCell && ( -
-

- Cell at row {String(selectedCell.row)}, col {String(selectedCell.col)} - {" "}({String(selectedCell.row_span)}x{String(selectedCell.col_span)}) -

-
-
- -
- - - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- - - Cancel - - -
- - +
+ {renderGrid(l.id, cells, props.cameras)}
- )} +