From f066cb6a8e52b5437129c5f70778b799b3327edc Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 12 Mar 2026 14:22:03 -0500 Subject: [PATCH] hvnc is now backstage mode + new context menu (looks much nicer). --- Overlord-Server/public/assets/main.css | 24 +- Overlord-Server/public/assets/main.js | 7 +- Overlord-Server/public/assets/render.js | 49 ++++ Overlord-Server/public/assets/ui.js | 353 ++++++++++++++++++++---- Overlord-Server/public/hvnc.html | 2 +- 5 files changed, 381 insertions(+), 54 deletions(-) diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index b66cc47..09ca346 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -649,6 +649,18 @@ p { border-color: rgba(255, 99, 99, 0.2); } +#command-menu .menu-group-toggle { + transition: background-color 0.16s ease; +} + +#command-menu .menu-group-chevron { + transition: transform 0.18s ease; +} + +#command-menu .menu-group-toggle[aria-expanded="false"] .menu-group-chevron { + transform: rotate(-90deg); +} + .pagination { display: flex; align-items: center; @@ -703,13 +715,23 @@ p { max-height: calc(100vh - 20px); overflow-y: auto; font-size: 14px; + padding: 10px; + border-radius: 14px; } #command-menu button { - padding: 10px 12px; + padding: 11px 12px; font-size: 14px; } + #command-menu .menu-group { + border-radius: 12px; + } + + #command-menu .menu-group-body { + gap: 6px; + } + #command-menu i { font-size: 14px; } diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index fe2fbd7..fc7c44a 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -122,8 +122,8 @@ function applyMenuSupportRules(clientId) { btn.title = enabled ? "" : reason; }; - const hvncBtn = menu.querySelector('[data-open="hvnc"]'); - setAvailability(hvncBtn, isWindows, "HVNC is only supported on Windows clients."); + const hvncBtn = menu.querySelector('[data-open="Backstage"]'); + setAvailability(hvncBtn, isWindows, "Backstage is only supported on Windows clients."); const webcamBtn = menu.querySelector('[data-open="webcam"]'); setAvailability(webcamBtn, isWindows, "Webcam viewer is only supported on Windows clients."); @@ -621,6 +621,7 @@ window.addEventListener("click", (e) => { menu.addEventListener("click", async (e) => { const target = e.target.closest("button"); if (!target || !contextCard) return; + if (target.dataset.groupToggle) return; if (target.disabled || target.getAttribute("aria-disabled") === "true") { return; } @@ -667,7 +668,7 @@ menu.addEventListener("click", async (e) => { closeMenu(clearContext); return; } - if (open === "hvnc") { + if (open === "Backstage") { window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener"); closeMenu(clearContext); return; diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 80b71b9..8007c5b 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -28,6 +28,8 @@ export function createRenderer({ const isViewer = userRole === "viewer"; const MAX_ANIMATED_CARDS = 120; const INSERT_BATCH_SIZE = 40; + const TOUCH_LONG_PRESS_MS = 520; + const TOUCH_MOVE_CANCEL_PX = 10; let renderToken = 0; function renderMerge(data) { @@ -128,6 +130,18 @@ export function createRenderer({ const card = document.createElement("article"); card.dataset.id = client.id; card.dataset.hwid = client.hwid || ""; + let longPressTimer = null; + let longPressTriggered = false; + let pointerStartX = 0; + let pointerStartY = 0; + + const clearLongPress = () => { + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + }; + updateCard(card, client); card.addEventListener("contextmenu", (e) => { e.preventDefault(); @@ -138,6 +152,35 @@ export function createRenderer({ openMenu(clientId, clientX, clientY); }); + card.addEventListener("pointerdown", (e) => { + if (isViewer || e.pointerType !== "touch") return; + if (e.target.closest("button") || e.target.closest(".client-checkbox")) return; + const clientId = card.dataset.id; + if (!clientId) return; + + longPressTriggered = false; + pointerStartX = e.clientX; + pointerStartY = e.clientY; + clearLongPress(); + longPressTimer = setTimeout(() => { + longPressTriggered = true; + openMenu(clientId, e.clientX, e.clientY); + }, TOUCH_LONG_PRESS_MS); + }); + + card.addEventListener("pointermove", (e) => { + if (!longPressTimer || e.pointerType !== "touch") return; + const movedX = Math.abs(e.clientX - pointerStartX); + const movedY = Math.abs(e.clientY - pointerStartY); + if (movedX > TOUCH_MOVE_CANCEL_PX || movedY > TOUCH_MOVE_CANCEL_PX) { + clearLongPress(); + } + }); + + card.addEventListener("pointerup", clearLongPress); + card.addEventListener("pointercancel", clearLongPress); + card.addEventListener("pointerleave", clearLongPress); + if (options.animate) { card.classList.add("card-animate"); card.style.animationDelay = `${(options.delayIndex || 0) * 0.05}s`; @@ -274,6 +317,12 @@ export function createRenderer({ ?.addEventListener("click", () => openModal(client.thumbnail)); card.onclick = (e) => { + if (longPressTriggered) { + longPressTriggered = false; + e.preventDefault(); + e.stopPropagation(); + return; + } if (e.target.closest(".command-btn") || e.target.closest("button")) return; if (e.target.closest(".client-checkbox")) return; diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 74a6485..b8074c7 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -1,34 +1,291 @@ +// ── Context menu data ───────────────────────────────────────────────────────── +const MENU_GROUPS = [ + { + id: "remote-access", + label: "Remote Access", + icon: "fa-solid fa-plug", + color: "text-indigo-400", + items: [ + { label: "Console", icon: "fa-solid fa-terminal", icolor: "text-emerald-400", open: "console" }, + { label: "Remote Desktop", icon: "fa-solid fa-desktop", icolor: "text-purple-400", open: "remotedesktop" }, + { label: "Backstage", icon: "fa-solid fa-ghost", icolor: "text-violet-400", open: "Backstage" }, + { label: "Voice", icon: "fa-solid fa-headset", icolor: "text-teal-400", open: "voice" }, + ], + }, + { + id: "monitoring", + label: "Monitoring", + icon: "fa-solid fa-eye", + color: "text-cyan-400", + items: [ + { label: "Webcam", icon: "fa-solid fa-video", icolor: "text-emerald-400", open: "webcam" }, + { label: "Keylogger", icon: "fa-solid fa-keyboard", icolor: "text-yellow-400", open: "keylogger" }, + { label: "Process Manager", icon: "fa-solid fa-list-check", icolor: "text-orange-400", open: "processes" }, + ], + }, + { + id: "system", + label: "System", + icon: "fa-solid fa-server", + color: "text-blue-400", + items: [ + { label: "File Browser", icon: "fa-solid fa-folder-tree", icolor: "text-blue-400", open: "files" }, + { label: "SOCKS5 Proxy", icon: "fa-solid fa-network-wired", icolor: "text-sky-400", open: "proxy" }, + { label: "Execution", icon: "fa-solid fa-rocket", icolor: "text-cyan-400", open: "silent-exec", id: "menu-silent-exec", hidden: true }, + ], + }, + { + id: "agent", + label: "Agent", + icon: "fa-solid fa-robot", + color: "text-slate-400", + items: [ + { label: "Ping", icon: "fa-solid fa-satellite-dish", icolor: "text-slate-300", action: "ping" }, + { label: "Reconnect", icon: "fa-solid fa-rotate", icolor: "text-slate-300", action: "reconnect" }, + { label: "Set Nickname", icon: "fa-solid fa-signature", icolor: "text-slate-300", action: "set-nickname" }, + { divider: true }, + { label: "Disconnect", icon: "fa-solid fa-plug-circle-xmark", icolor: "text-red-400", action: "disconnect" }, + { label: "Uninstall", icon: "fa-solid fa-trash", icolor: "text-red-300", action: "uninstall" }, + { label: "Remove From Dashboard", icon: "fa-solid fa-user-xmark", icolor: "text-rose-300", action: "remove-dashboard" }, + ], + }, +]; + +// ── Styles ──────────────────────────────────────────────────────────────────── +const menuStyle = document.createElement("style"); +menuStyle.textContent = ` +#command-menu { + position: fixed; + display: none; + flex-direction: row; + align-items: flex-start; + z-index: 9999; + filter: drop-shadow(0 8px 40px rgba(0,0,0,0.7)); +} +#ctx-main { + background: #141c2b; + border: 1px solid rgba(148,163,184,0.14); + border-radius: 8px; + padding: 4px; + min-width: 196px; + display: flex; + flex-direction: column; + gap: 1px; + position: relative; +} +#ctx-sub { + background: #141c2b; + border: 1px solid rgba(148,163,184,0.14); + border-radius: 8px; + padding: 4px; + min-width: 188px; + position: absolute; + left: calc(100% + 5px); + top: 0; + display: none; + flex-direction: column; + gap: 1px; + z-index: 1; +} +.ctx-row { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 7px 10px; + border-radius: 5px; + font-size: 13px; + font-weight: 500; + color: #cbd5e1; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + white-space: nowrap; + user-select: none; + -webkit-user-select: none; + transition: background 0.1s, color 0.1s; +} +.ctx-row:hover, +.ctx-row.ctx-active { + background: rgba(71,85,105,0.55); + color: #f1f5f9; +} +.ctx-row.ctx-active .ctx-chevron { + opacity: 1; +} +.ctx-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 7px 10px; + border-radius: 5px; + font-size: 13px; + font-weight: 500; + color: #cbd5e1; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + white-space: nowrap; + user-select: none; + -webkit-user-select: none; + transition: background 0.1s, color 0.1s; +} +.ctx-item:hover:not([disabled]):not([aria-disabled="true"]) { + background: rgba(71,85,105,0.55); + color: #f1f5f9; +} +.ctx-item[disabled], +.ctx-item[aria-disabled="true"] { + opacity: 0.38; + cursor: not-allowed; +} +.ctx-divider { + height: 1px; + background: rgba(148,163,184,0.13); + margin: 3px 4px; +} +.ctx-sub-panel { + display: none; + flex-direction: column; + gap: 1px; +} +.ctx-icon { + width: 16px; + text-align: center; + flex-shrink: 0; + font-size: 13px; +} +.ctx-chevron { + margin-left: auto; + font-size: 10px; + opacity: 0.4; + transition: opacity 0.1s; + flex-shrink: 0; +} +/* Allow classList.add/remove("hidden") to work on items inside the menu */ +#command-menu .hidden { display: none !important; } +/* Mobile: stack submenu below the main column */ +@media (max-width: 600px) { + #command-menu { flex-direction: column; max-width: calc(100vw - 16px); } + #ctx-sub { position: static; margin-top: 0; min-width: 0; width: 100%; border-radius: 0 0 8px 8px; border-top: 1px solid rgba(148,163,184,0.08); left: auto; right: auto; } + #ctx-main { border-radius: 8px 8px 0 0; } +} +`; +document.head.appendChild(menuStyle); + +// ── Build DOM ───────────────────────────────────────────────────────────────── +function buildItemHTML(item) { + if (item.divider) return `
`; + const dataAttr = item.open ? `data-open="${item.open}"` : `data-action="${item.action}"`; + const idAttr = item.id ? `id="${item.id}"` : ""; + const styleAttr = item.hidden ? `style="display:none"` : ""; + return ``; +} + +const mainRowsHTML = + MENU_GROUPS.map(g => + `` + ).join("") + + `
` + + ``; + +const subPanelsHTML = + MENU_GROUPS.map(g => + `
${g.items.map(buildItemHTML).join("")}
` + ).join("") + + `
`; + const menu = document.createElement("div"); menu.id = "command-menu"; -menu.className = - "fixed z-50 w-48 hidden flex-col gap-1.5 rounded-xl border border-slate-800 bg-slate-900/90 p-3 shadow-xl backdrop-blur overflow-y-auto overscroll-contain"; -menu.style.maxHeight = "min(70vh, 520px)"; -menu.innerHTML = ` - - - - - - - - - - -
- - - - - - - -`; +menu.setAttribute("role", "menu"); +menu.setAttribute("aria-hidden", "true"); +menu.innerHTML = `
${mainRowsHTML}
${subPanelsHTML}
`; document.body.appendChild(menu); +const ctxMain = menu.querySelector("#ctx-main"); +const ctxSub = menu.querySelector("#ctx-sub"); + +// ── Submenu interaction ─────────────────────────────────────────────────────── +let activeGroupId = null; +let _hideTimer = null; + +function showSubmenu(groupId, rowEl) { + if (activeGroupId === groupId) return; + activeGroupId = groupId; + + ctxMain.querySelectorAll(".ctx-row").forEach(r => r.classList.remove("ctx-active")); + rowEl.classList.add("ctx-active"); + + ctxSub.querySelectorAll(".ctx-sub-panel").forEach(p => { p.style.display = "none"; }); + const panel = ctxSub.querySelector(`[data-for="${groupId}"]`); + if (!panel) { ctxSub.style.display = "none"; return; } + panel.style.display = "flex"; + ctxSub.style.display = "flex"; + + // Align submenu vertically with the hovered row, then clamp to viewport + requestAnimationFrame(() => { + const rowRect = rowEl.getBoundingClientRect(); + const mainRect = ctxMain.getBoundingClientRect(); + let offsetY = rowRect.top - mainRect.top; + + const subH = ctxSub.offsetHeight; + const menuTop = parseFloat(menu.style.top) || 0; + if (menuTop + offsetY + subH > window.innerHeight - 8) { + offsetY = Math.max(0, window.innerHeight - 8 - subH - menuTop); + } + ctxSub.style.top = offsetY + "px"; + + // Flip to left if submenu overflows right edge + const subW = ctxSub.offsetWidth; + if (mainRect.right + 5 + subW > window.innerWidth - 8) { + ctxSub.style.left = "auto"; + ctxSub.style.right = "calc(100% + 5px)"; + } else { + ctxSub.style.left = "calc(100% + 5px)"; + ctxSub.style.right = "auto"; + } + }); +} + +function hideSubmenu() { + clearTimeout(_hideTimer); + _hideTimer = null; + activeGroupId = null; + ctxSub.style.display = "none"; + ctxSub.querySelectorAll(".ctx-sub-panel").forEach(p => { p.style.display = "none"; }); + ctxMain.querySelectorAll(".ctx-row").forEach(r => r.classList.remove("ctx-active")); +} + +// Desktop: hover to open group +ctxMain.querySelectorAll(".ctx-row").forEach(rowEl => { + rowEl.addEventListener("mouseenter", () => { + const groupId = rowEl.dataset.groupToggle; + if (groupId) showSubmenu(groupId, rowEl); + }); +}); + +// Debounced hide — prevents the 5px gap between panels from closing the submenu +function scheduleHide() { _hideTimer = setTimeout(hideSubmenu, 150); } +function cancelHide() { clearTimeout(_hideTimer); _hideTimer = null; } + +ctxMain.addEventListener("mouseleave", scheduleHide); +ctxMain.addEventListener("mouseenter", cancelHide); +ctxSub.addEventListener("mouseenter", cancelHide); +ctxSub.addEventListener("mouseleave", scheduleHide); + +// Touch / mobile: tap to toggle group +ctxMain.querySelectorAll(".ctx-row").forEach(rowEl => { + rowEl.addEventListener("click", (e) => { + const groupId = rowEl.dataset.groupToggle; + if (!groupId) return; + e.stopPropagation(); + if (activeGroupId === groupId) { hideSubmenu(); } else { showSubmenu(groupId, rowEl); } + }); +}); + const modal = document.createElement("div"); modal.className = "modal fixed inset-0 z-40 hidden items-center justify-center bg-black/80 backdrop-blur"; @@ -39,37 +296,35 @@ const modalImg = modal.querySelector("#modal-img"); export function openMenu(clientId, x, y, setContext, options = {}) { if (setContext) setContext(clientId); + // Toggle remove-dashboard visibility const removeBtn = menu.querySelector('[data-action="remove-dashboard"]'); if (removeBtn) { - const isOnline = options.isOnline === true; - removeBtn.classList.toggle("hidden", isOnline); + removeBtn.style.display = options.isOnline === true ? "none" : ""; } - menu.classList.remove("hidden"); + // Reset submenu state + hideSubmenu(); - const menuWidth = 192; - const menuHeight = menu.offsetHeight || 400; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; + // Show menu off-screen first so we can measure, then reposition + menu.style.left = "-9999px"; + menu.style.top = "-9999px"; + menu.style.display = "flex"; + menu.setAttribute("aria-hidden", "false"); - let left = x; - if (left + menuWidth > viewportWidth - 10) { - left = viewportWidth - menuWidth - 10; - } - if (left < 10) left = 10; - - let top = y; - if (top + menuHeight > viewportHeight - 10) { - top = viewportHeight - menuHeight - 10; - } - if (top < 10) top = 10; - - menu.style.left = `${left}px`; - menu.style.top = `${top}px`; + requestAnimationFrame(() => { + const mw = ctxMain.offsetWidth; + const mh = ctxMain.offsetHeight; + const vw = window.innerWidth; + const vh = window.innerHeight; + menu.style.left = Math.max(8, Math.min(x, vw - mw - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(y, vh - mh - 8)) + "px"; + }); } export function closeMenu(clearContext) { - menu.classList.add("hidden"); + menu.style.display = "none"; + menu.setAttribute("aria-hidden", "true"); + hideSubmenu(); if (clearContext) clearContext(); } diff --git a/Overlord-Server/public/hvnc.html b/Overlord-Server/public/hvnc.html index c190e6e..92e290c 100644 --- a/Overlord-Server/public/hvnc.html +++ b/Overlord-Server/public/hvnc.html @@ -3,7 +3,7 @@ - Overlord HVNC + Overlord Backstage