diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 47e7c60..6b4e62c 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -89,6 +89,83 @@ function rtspWithCredentials(raw: string, username: string, password: string): s } } +function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { + return aStart < bEnd && bStart < aEnd; +} + +function shiftCellsForExpansion( + deps: AdminDeps, + layoutId: number, + cellId: number, + direction: "left" | "right" | "above" | "bottom", +): void { + const cell = deps.repo.getLayoutCellById(cellId); + if (!cell || cell.layout_id !== layoutId) return; + + const cells = deps.repo.layoutCells(layoutId).filter((c) => c.id !== cellId); + const rowStart = cell.row; + const rowEnd = cell.row + cell.row_span; + const colStart = cell.col; + const colEnd = cell.col + cell.col_span; + + if (direction === "right") { + for (const c of cells) { + if (c.col >= colEnd && rangesOverlap(c.row, c.row + c.row_span, rowStart, rowEnd)) { + deps.repo.updateLayoutCell(c.id, { col: c.col + 1 }); + } + } + deps.repo.updateLayoutCell(cell.id, { col_span: cell.col_span + 1 }); + } else if (direction === "bottom") { + for (const c of cells) { + if (c.row >= rowEnd && rangesOverlap(c.col, c.col + c.col_span, colStart, colEnd)) { + deps.repo.updateLayoutCell(c.id, { row: c.row + 1 }); + } + } + deps.repo.updateLayoutCell(cell.id, { row_span: cell.row_span + 1 }); + } else if (direction === "left") { + const insertCol = Math.max(0, cell.col - 1); + for (const c of cells) { + if (c.col >= insertCol && rangesOverlap(c.row, c.row + c.row_span, rowStart, rowEnd)) { + deps.repo.updateLayoutCell(c.id, { col: c.col + 1 }); + } + } + deps.repo.updateLayoutCell(cell.id, { + col: insertCol, + col_span: cell.col_span + 1, + }); + } else if (direction === "above") { + const insertRow = Math.max(0, cell.row - 1); + for (const c of cells) { + if (c.row >= insertRow && rangesOverlap(c.col, c.col + c.col_span, colStart, colEnd)) { + deps.repo.updateLayoutCell(c.id, { row: c.row + 1 }); + } + } + deps.repo.updateLayoutCell(cell.id, { + row: insertRow, + row_span: cell.row_span + 1, + }); + } +} + +function shiftCellsForInsertion( + deps: AdminDeps, + layoutId: number, + axis: "row" | "col", + fromIndex: number, + crossStart: number, + crossEnd: number, +): void { + for (const c of deps.repo.layoutCells(layoutId)) { + if (axis === "col") { + if (c.col >= fromIndex && rangesOverlap(c.row, c.row + c.row_span, crossStart, crossEnd)) { + deps.repo.updateLayoutCell(c.id, { col: c.col + 1 }); + } + } else if (c.row >= fromIndex && rangesOverlap(c.col, c.col + c.col_span, crossStart, crossEnd)) { + deps.repo.updateLayoutCell(c.id, { row: c.row + 1 }); + } + } +} + export function registerAdminRoutes(app: H3, deps: AdminDeps): void { // ---- Overview ------------------------------------------------------------- @@ -547,25 +624,19 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (direction === "right") { row = ref.row; col = ref.col + ref.col_span; + shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1); } else if (direction === "bottom") { row = ref.row + ref.row_span; col = ref.col; + shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1); } else if (direction === "left") { row = ref.row; - if (ref.col === 0) { - deps.repo.shiftCellsForLayout(layoutId, "col", 0, 1); - col = 0; - } else { - col = ref.col - 1; - } + col = Math.max(0, ref.col - 1); + shiftCellsForInsertion(deps, layoutId, "col", col, row, row + 1); } else if (direction === "above") { col = ref.col; - if (ref.row === 0) { - deps.repo.shiftCellsForLayout(layoutId, "row", 0, 1); - row = 0; - } else { - row = ref.row - 1; - } + row = Math.max(0, ref.row - 1); + shiftCellsForInsertion(deps, layoutId, "row", row, col, col + 1); } } else { // Explicit position — accept top-level row/col or nested position. @@ -672,9 +743,17 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const body = await readBody>(event); const dim = String(body?.["dim"] ?? ""); const delta = Number(body?.["delta"] ?? 0) || 0; + const direction = String(body?.["direction"] ?? ""); const cell = deps.repo.getLayoutCellById(cellId); - if (cell && cell.layout_id === layoutId && (dim === "row_span" || dim === "col_span") && delta !== 0) { + if ( + cell + && cell.layout_id === layoutId + && (direction === "left" || direction === "right" || direction === "above" || direction === "bottom") + ) { + shiftCellsForExpansion(deps, layoutId, cellId, direction); + notifyKiosks(); + } else 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) { diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 968584a..56f03bb 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1418,23 +1418,28 @@ interface LayoutEditPageProps { 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 { 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: visible; } .layout-cell:hover { background: #f0f7ff; } .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; } -.layout-cell-add:hover { background: #1e40af; opacity: 1; } -.layout-cell-add-top { top: -12px; left: 50%; transform: translateX(-50%); } -.layout-cell-add-right { right: -12px; top: 50%; transform: translateY(-50%); } -.layout-cell-add-bottom { bottom: -12px; left: 50%; transform: translateX(-50%); } -.layout-cell-add-left { left: -12px; top: 50%; transform: translateY(-50%); } +.layout-cell-side { position: absolute; opacity: 0; transition: opacity 0.2s; z-index: 3; } +.layout-cell:hover .layout-cell-side, .layout-cell-side:hover { opacity: 1; } +.layout-cell-side-top { top: -12px; left: 50%; transform: translateX(-50%); } +.layout-cell-side-right { right: -12px; top: 50%; transform: translateY(-50%); } +.layout-cell-side-bottom { bottom: -12px; left: 50%; transform: translateX(-50%); } +.layout-cell-side-left { left: -12px; top: 50%; transform: translateY(-50%); } +.layout-cell-side-trigger { background: #2563eb; color: #fff; border: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1; padding: 0; } +.layout-cell-side-trigger:hover { background: #1e40af; } +.layout-cell-side-menu { display: none; position: absolute; gap: 4px; background: #111827; border-radius: 4px; padding: 4px; box-shadow: 0 8px 18px rgba(15, 23, 42, 0.25); } +.layout-cell-side:hover .layout-cell-side-menu { display: flex; } +.layout-cell-side-top .layout-cell-side-menu { bottom: 28px; left: 50%; transform: translateX(-50%); } +.layout-cell-side-bottom .layout-cell-side-menu { top: 28px; left: 50%; transform: translateX(-50%); } +.layout-cell-side-left .layout-cell-side-menu { right: 28px; top: 50%; transform: translateY(-50%); flex-direction: column; } +.layout-cell-side-right .layout-cell-side-menu { left: 28px; top: 50%; transform: translateY(-50%); flex-direction: column; } +.layout-cell-side-menu button { background: #fff; color: #111827; border: none; border-radius: 3px; cursor: pointer; font-size: 0.7rem; line-height: 1; padding: 5px 7px; white-space: nowrap; } +.layout-cell-side-menu button:hover { background: #dbeafe; color: #1e40af; } .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; } @@ -1585,55 +1590,30 @@ export function renderCell( {(["top", "right", "bottom", "left"] as const).map((dir) => { const directionParam = dir === "top" ? "above" : dir; return ( - +
+ +
+ + +
+
); })} - {/* resize buttons */} -
- - - - -
- {/* delete button */}