import { formatAgo, formatPing, countryToFlag, osBadge, archBadge, versionBadge, monitorsBadge, shortId, } from "./viewUtils.js"; function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } const ROW_SELECTOR = "[data-client-row]"; function pingTone(ms) { if (ms === null || ms === undefined) return "ping-unknown"; if (ms < 30) return "ping-good"; if (ms < 80) return "ping-mid"; return "ping-bad"; } function metaSeparator() { return ``; } const FAUX_PALETTES = [ ["#1e3a8a", "#0ea5e9", "#22d3ee"], ["#0f172a", "#475569", "#94a3b8"], ["#3b0764", "#a21caf", "#f472b6"], ["#064e3b", "#10b981", "#bbf7d0"], ["#7c2d12", "#ea580c", "#fdba74"], ["#082f49", "#0284c7", "#7dd3fc"], ]; function paletteFor(id) { const s = String(id || ""); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return FAUX_PALETTES[h % FAUX_PALETTES.length]; } function fauxDesktopHtml(client, opts = {}) { const palette = paletteFor(client.id); const op = !client.online ? 0.3 : 1.0; const small = !!opts.small; const dotR = small ? 4 : 6; return `
`; } function thumbHtml(client, { width, height, small = false } = {}) { const hasThumb = !!client.hasThumbnail; const version = Number(client.thumbnailVersion) || 0; const opacityStyle = client.online ? "" : "opacity:0.35"; const idAttr = escapeHtml(client.id); const initialSrc = hasThumb ? `/api/clients/${encodeURIComponent(client.id)}/thumbnail${version ? `?v=${version}` : ""}` : ""; const initialDisplay = hasThumb ? "display:block" : "display:none"; return `
${fauxDesktopHtml(client, { small })}
`; } function statusDot(client) { return ``; } function groupPillHtml(client) { const name = String(client.groupName || "").trim(); if (!name) return ""; const color = String(client.groupColor || "").trim() || "#64748b"; return `${escapeHtml(name)}`; } function shortenCpu(raw = "") { return String(raw) .replace(/\(R\)|\(TM\)|\(tm\)|\(r\)/g, "") .replace(/\b(CPU|Processor|Genuine|Intel|AMD)\b/gi, (m) => (m.toUpperCase() === "INTEL" || m.toUpperCase() === "AMD" ? m : "")) .replace(/@\s*[\d.]+\s*GHz.*$/i, "") .replace(/\s+/g, " ") .trim() .slice(0, 38); } function shortOsLabel(osRaw = "") { const o = String(osRaw).toLowerCase(); if (o.includes("windows 11")) return "Win 11"; if (o.includes("windows 10")) return "Win 10"; if (o.includes("windows")) return "Win"; if (o.includes("ubuntu")) return "Ubuntu"; if (o.includes("debian")) return "Debian"; if (o.includes("arch")) return "Arch"; if (o.includes("kali")) return "Kali"; if (o.includes("fedora")) return "Fedora"; if (o.includes("mac") || o.includes("darwin")) return "macOS"; if (o.includes("linux")) return "Linux"; return osRaw || "?"; } export function createRenderer({ grid, totalPill, pageLabel, openMenu, openModal, requestPreview, requestThumbnail, pingClient, userRole, }) { 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; let gridDelegated = false; let currentLayout = (grid?.dataset.layout || "rows").toLowerCase(); function getCardContainer() { if (currentLayout === "table") { return grid.querySelector("tbody.clients-table-body") || grid; } return grid; } function ensureLayoutScaffold() { if (currentLayout === "table") { if (grid.querySelector("table.clients-table")) return; grid.innerHTML = `
Client Status Last seen System Ping Group Actions
`; } else { const t = grid.querySelector("table.clients-table"); if (t) t.remove(); } } function setLayout(layout) { const next = ["rows", "table", "cards"].includes(layout) ? layout : "rows"; if (currentLayout === next && grid.dataset.layout === next) return; currentLayout = next; grid.dataset.layout = next; grid.innerHTML = ""; ensureLayoutScaffold(); } /* ── Event delegation ─────────────────────────────────────────── */ function setupGridDelegation() { if (gridDelegated) return; gridDelegated = true; grid.addEventListener("click", (e) => { const card = e.target.closest(ROW_SELECTOR); if (!card) return; const clientId = card.dataset.id; if (e.target.closest(".client-checkbox")) { e.stopPropagation(); if (window.toggleClientSelection) window.toggleClientSelection(clientId); return; } const bookmarkBtn = e.target.closest(".bookmark-btn"); if (bookmarkBtn) { e.stopPropagation(); handleBookmarkClick(card, bookmarkBtn); return; } const copyBtn = e.target.closest(".copy-id-btn"); if (copyBtn) { e.stopPropagation(); const fullId = copyBtn.dataset.copy; if (fullId) { navigator.clipboard.writeText(fullId).then(() => { const icon = copyBtn.querySelector(".copy-id-icon"); if (icon) { icon.className = "fa-solid fa-check copy-id-icon"; setTimeout(() => { icon.className = "fa-regular fa-copy copy-id-icon"; }, 1200); } }).catch(() => {}); } return; } if (e.target.closest(".command-btn")) { e.stopPropagation(); const rect = e.target.closest(".command-btn").getBoundingClientRect(); openMenu(clientId, rect.right, rect.bottom); return; } if (e.target.closest(".ban-btn")) { e.stopPropagation(); if (window.banClient) window.banClient(clientId); return; } if (e.target.closest(".kebab-btn")) { e.stopPropagation(); const rect = e.target.closest(".kebab-btn").getBoundingClientRect(); openMenu(clientId, rect.right, rect.bottom); return; } if (e.target.closest(".cv-ping-btn")) { e.stopPropagation(); if (pingClient) pingClient(clientId); return; } if (e.target.closest(".client-tag-toggle")) { e.stopPropagation(); handleTagToggle(card); return; } if (e.target.closest(".hw-toggle")) { e.stopPropagation(); handleHwToggle(card); return; } if (e.target.closest(".cv-expand-btn")) { e.stopPropagation(); handleExpandToggle(card); return; } const thumbImg = e.target.closest(".thumb-img"); if (thumbImg) { if (thumbImg.src) openModal(thumbImg.src); return; } if (card._longPressTriggered) { card._longPressTriggered = false; e.preventDefault(); e.stopPropagation(); return; } if (e.target.closest("button")) return; if (e.target.closest(".client-checkbox")) return; const checkbox = card.querySelector(".client-checkbox"); if (e.ctrlKey || e.metaKey || e.shiftKey) { if (checkbox && !checkbox.disabled) { checkbox.checked = !checkbox.checked; if (window.toggleClientSelection) window.toggleClientSelection(clientId); } return; } if (card.dataset.online !== "true") return; if (pingClient) pingClient(clientId); requestThumbnail(clientId); }); grid.addEventListener("contextmenu", (e) => { const card = e.target.closest(ROW_SELECTOR); if (!card || isViewer) return; e.preventDefault(); openMenu(card.dataset.id, e.clientX, e.clientY); }); grid.addEventListener("pointerdown", (e) => { if (isViewer || e.pointerType !== "touch") return; if (e.target.closest("button") || e.target.closest(".client-checkbox")) return; const card = e.target.closest(ROW_SELECTOR); if (!card) return; card._longPressTriggered = false; card._pointerStartX = e.clientX; card._pointerStartY = e.clientY; clearTimeout(card._longPressTimer); card._longPressTimer = setTimeout(() => { card._longPressTriggered = true; openMenu(card.dataset.id, e.clientX, e.clientY); }, TOUCH_LONG_PRESS_MS); }); grid.addEventListener("pointermove", (e) => { if (e.pointerType !== "touch") return; const card = e.target.closest(ROW_SELECTOR); if (!card || !card._longPressTimer) return; if ( Math.abs(e.clientX - card._pointerStartX) > TOUCH_MOVE_CANCEL_PX || Math.abs(e.clientY - card._pointerStartY) > TOUCH_MOVE_CANCEL_PX ) { clearTimeout(card._longPressTimer); card._longPressTimer = null; } }); const clearLongPress = (e) => { if (e.pointerType !== "touch") return; const card = e.target.closest(ROW_SELECTOR); if (card) { clearTimeout(card._longPressTimer); card._longPressTimer = null; } }; grid.addEventListener("pointerup", clearLongPress); grid.addEventListener("pointercancel", clearLongPress); grid.addEventListener("pointerleave", clearLongPress, true); } async function handleBookmarkClick(card, btn) { const id = card.dataset.id; const isBookmarked = card.dataset.bookmarked === "true"; try { const res = await fetch(`/api/clients/${id}/bookmark`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bookmarked: !isBookmarked }), }); if (res.ok) { card.dataset.bookmarked = String(!isBookmarked); const icon = btn.querySelector("i"); if (icon) { icon.classList.toggle("fa-solid", !isBookmarked); icon.classList.toggle("fa-regular", isBookmarked); } btn.classList.toggle("is-on", !isBookmarked); btn.title = !isBookmarked ? "Remove bookmark" : "Bookmark"; } } catch (err) { console.error("bookmark toggle failed", err); } } function handleTagToggle(card) { const notePanel = card.querySelector(".client-tag-note"); if (!notePanel) return; const expanded = notePanel.classList.toggle("hidden") === false; card.dataset.tagNoteExpanded = expanded ? "true" : "false"; const tagToggle = card.querySelector(".client-tag-toggle"); tagToggle?.setAttribute("aria-expanded", expanded ? "true" : "false"); const chevron = tagToggle?.querySelector(".fa-chevron-up, .fa-chevron-down"); if (chevron) { chevron.classList.toggle("fa-chevron-up", expanded); chevron.classList.toggle("fa-chevron-down", !expanded); } } function handleHwToggle(card) { const hwPanel = card.querySelector(".hw-panel"); if (!hwPanel) return; const expanded = hwPanel.classList.toggle("hidden") === false; card.dataset.hwExpanded = expanded ? "true" : "false"; const hwToggle = card.querySelector(".hw-toggle"); hwToggle?.setAttribute("aria-expanded", expanded ? "true" : "false"); const chevron = hwToggle?.querySelector(".fa-chevron-up, .fa-chevron-down"); if (chevron) { chevron.classList.toggle("fa-chevron-up", expanded); chevron.classList.toggle("fa-chevron-down", !expanded); } } function handleExpandToggle(card) { const panel = card.querySelector(".cv-expand-panel"); if (!panel) return; const expanded = panel.classList.toggle("hidden") === false; card.dataset.expanded = expanded ? "true" : "false"; const btn = card.querySelector(".cv-expand-btn"); btn?.setAttribute("aria-expanded", expanded ? "true" : "false"); } function reorderCards(items) { const container = getCardContainer(); const cards = container.querySelectorAll(ROW_SELECTOR); let needsReorder = cards.length !== items.length; if (!needsReorder) { for (let i = 0; i < items.length; i++) { if (cards[i]?.dataset?.id !== items[i].id) { needsReorder = true; break; } } } if (!needsReorder) return; items.forEach((client) => { const card = container.querySelector(`${ROW_SELECTOR}[data-id="${client.id}"]`); if (card) container.appendChild(card); }); } function cardDigest(c) { return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`; } function renderMerge(data, options = {}) { setupGridDelegation(); ensureLayoutScaffold(); const container = getCardContainer(); const { reorder = false } = options; totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`; const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize)); pageLabel.textContent = `Page ${data.page} of ${totalPages}`; prevBtnState(data.page, totalPages); const items = data.items || []; const seen = new Set(); const newClients = []; const renderId = ++renderToken; const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const hadCards = container.querySelectorAll(ROW_SELECTOR).length > 0; const uninstallingIds = window.__uninstallingClientIds; items.forEach((client) => { seen.add(client.id); if (uninstallingIds && uninstallingIds.has(client.id)) return; const existing = container.querySelector(`${ROW_SELECTOR}[data-id="${client.id}"]`); if (existing) { if (existing.classList.contains("card-uninstalling")) return; const digest = cardDigest(client); if (existing._cardDigest === digest) return; existing._cardDigest = digest; updateCard(existing, client); return; } newClients.push(client); }); Array.from(container.querySelectorAll(ROW_SELECTOR)) .filter((el) => !seen.has(el.dataset.id) && !el.classList.contains("card-uninstalling")) .forEach((el) => el.remove()); if (reorder) { reorderCards(items); } if (newClients.length === 0) return; const allowAnimation = !hadCards && !prefersReducedMotion && items.length <= 1000 && currentLayout !== "table"; const animateLimit = Math.min(newClients.length, MAX_ANIMATED_CARDS); let idx = 0; const insertBatch = () => { if (renderId !== renderToken) return; const fragment = document.createDocumentFragment(); for ( let batch = 0; batch < INSERT_BATCH_SIZE && idx < newClients.length; batch++, idx++ ) { const client = newClients[idx]; const shouldAnimate = allowAnimation && idx < animateLimit; const card = buildCard(client, { animate: shouldAnimate, delayIndex: idx, }); fragment.appendChild(card); } container.appendChild(fragment); if (idx < newClients.length) { requestAnimationFrame(insertBatch); return; } reorderCards(items); }; insertBatch(); } function prevBtnState(currentPage, totalPages) { const prevBtn = document.getElementById("prev"); const nextBtn = document.getElementById("next"); if (prevBtn) prevBtn.disabled = currentPage <= 1; if (nextBtn) nextBtn.disabled = currentPage >= totalPages; } function setSharedDataset(el, client) { el.dataset.clientRow = currentLayout; el.dataset.id = client.id; el.dataset.hwid = client.hwid || ""; el.dataset.online = String(!!client.online); el.dataset.os = String(client.os || "").toLowerCase(); el.dataset.nickname = String(client.nickname || ""); el.dataset.customTag = String(client.customTag || ""); el.dataset.bookmarked = String(!!client.bookmarked); el.dataset.notificationsMuted = String(!!client.notificationsMuted); el.dataset.admin = String(!!client.isAdmin); el.dataset.groupId = String(client.groupId || ""); el.dataset.groupName = String(client.groupName || ""); el.dataset.groupColor = String(client.groupColor || ""); el._customTagNote = String(client.customTagNote || ""); if (!client.bookmarked && client.groupColor) { el.style.setProperty("--group-color", client.groupColor); } else { el.style.removeProperty("--group-color"); } } function buildCard(client, options = {}) { const node = currentLayout === "table" ? buildRowB(client, options) : currentLayout === "cards" ? buildCardC(client, options) : buildRowA(client, options); node._cardDigest = cardDigest(client); if (options.animate && currentLayout !== "table") { node.classList.add("card-animate"); node.style.animationDelay = `${(options.delayIndex || 0) * 0.05}s`; node.style.opacity = "0"; node.style.transform = "translateY(10px)"; if (typeof anime !== "undefined") { requestAnimationFrame(() => { anime({ targets: node, opacity: [0, 1], translateY: [10, 0], duration: 400, easing: "easeOutQuad", }); }); } } return node; } function updateCard(card, client) { const wasChecked = card.querySelector(".client-checkbox")?.checked || false; const wasTagNoteExpanded = card.dataset.tagNoteExpanded === "true"; const wasHwExpanded = card.dataset.hwExpanded === "true"; const wasExpanded = card.dataset.expanded === "true"; const fresh = buildCard(client, { animate: false }); fresh.dataset.tagNoteExpanded = wasTagNoteExpanded ? "true" : "false"; fresh.dataset.hwExpanded = wasHwExpanded ? "true" : "false"; fresh.dataset.expanded = wasExpanded ? "true" : "false"; if (wasTagNoteExpanded) fresh.querySelector(".client-tag-note")?.classList.remove("hidden"); if (wasHwExpanded) fresh.querySelector(".hw-panel")?.classList.remove("hidden"); if (wasExpanded) fresh.querySelector(".cv-expand-panel")?.classList.remove("hidden"); const cb = fresh.querySelector(".client-checkbox"); if (cb) { const isSelected = typeof window.isClientSelected === "function" ? window.isClientSelected(client.id) : false; if ((wasChecked || isSelected) && client.online) cb.checked = true; } card.replaceWith(fresh); } /* ── VARIANT A: Row cards ─────────────────────────────────────── */ function buildRowA(client, _options) { const article = document.createElement("article"); article.className = `cv-row ${client.online ? "" : "cv-offline"} ${client.bookmarked ? "is-bookmarked" : ""}`; setSharedDataset(article, client); const os = osBadge(client.os || "unknown"); const arch = archBadge(client.arch || ""); const ver = versionBadge(client.version || ""); const mons = monitorsBadge(client.monitors); const deviceId = shortId(client.id); const hwid = shortId(client.hwid || ""); const nickname = String(client.nickname || "").trim(); const customTag = String(client.customTag || "").trim(); const customTagNote = String(client.customTagNote || ""); const displayName = nickname || client.host || deviceId; const userLine = client.user || client.host || deviceId; const hasTagNote = customTag.length > 0 && customTagNote.length > 0; const hasHwInfo = !!(client.cpu || client.gpu || client.ram); const verLatest = String(client.version || "").startsWith("2.0."); const metaParts = [ ` ${escapeHtml(shortOsLabel(client.os))}`, `${escapeHtml(arch.label)}`, ` ${escapeHtml(ver.label)}`, ` ${client.monitors || 1}`, nickname && client.host && nickname !== client.host ? ` ${escapeHtml(client.host)}` : "", client.ip ? ` ${escapeHtml(client.ip)}` : "", hwid ? ` ${escapeHtml(hwid)}` : "", ].filter(Boolean); const meta = metaParts.join(metaSeparator()); article.innerHTML = `
${thumbHtml(client, { width: 168, height: 96, small: false })}
${statusDot(client)} ${countryToFlag(client.country)} ${escapeHtml(displayName)} ${client.isAdmin ? `` : ""} ${client.elevation === "system" ? `` : ""} ${client.elevation === "trustedinstaller" ? `` : ""} ${client.notificationsMuted ? `` : ""}
${escapeHtml(userLine)}
${meta}
${customTag ? `` : ""} ${hasTagNote ? `` : ""}
${formatAgo(client.lastSeen)} ${formatPing(client.pingMs)}
${groupPillHtml(client) || `—`}
${isViewer ? "" : ``} ${isViewer ? "" : ``}
`; return article; } /* ── VARIANT B: Dense table ───────────────────────────────────── */ function buildRowB(client, _options) { const tr = document.createElement("tr"); tr.className = `cv-trow ${client.online ? "" : "cv-offline"} ${client.bookmarked ? "is-bookmarked" : ""}`; setSharedDataset(tr, client); const os = osBadge(client.os || "unknown"); const arch = archBadge(client.arch || ""); const deviceId = shortId(client.id); const nickname = String(client.nickname || "").trim(); const displayName = nickname || client.host || deviceId; const userLine = client.user || client.host || deviceId; tr.innerHTML = ` ${thumbHtml(client, { width: 80, height: 50, small: true })}
${countryToFlag(client.country)}
${escapeHtml(displayName)} ${client.isAdmin ? `` : ""} ${client.elevation === "system" ? `` : ""} ${client.elevation === "trustedinstaller" ? `` : ""} ${client.notificationsMuted ? `` : ""} ${escapeHtml(userLine)}${client.ip ? `· ${escapeHtml(client.ip)}` : ""}
${statusDot(client)} ${client.online ? "Online" : "Offline"} ${formatAgo(client.lastSeen)} ${escapeHtml(shortOsLabel(client.os))} ${escapeHtml(arch.label)} ${formatPing(client.pingMs)} ${groupPillHtml(client) || `—`}
${isViewer ? "" : ``} ${isViewer ? "" : ``}
`; return tr; } /* ── VARIANT C: Card wall ─────────────────────────────────────── */ function buildCardC(client, _options) { const article = document.createElement("article"); article.className = `cv-card ${client.online ? "" : "cv-offline"} ${client.bookmarked ? "is-bookmarked" : ""}`; setSharedDataset(article, client); const os = osBadge(client.os || "unknown"); const arch = archBadge(client.arch || ""); const ver = versionBadge(client.version || ""); const deviceId = shortId(client.id); const hwidShort = shortId(client.hwid || ""); const nickname = String(client.nickname || "").trim(); const customTag = String(client.customTag || "").trim(); const displayName = nickname || client.host || deviceId; const userLine = client.user || "unknown"; const verLatest = String(client.version || "").startsWith("2.0."); const hasGroup = !!String(client.groupName || "").trim(); const showHost = nickname && client.host && nickname !== client.host; const cpuShort = client.cpu ? shortenCpu(client.cpu) : ""; const metaParts = [ ` ${escapeHtml(shortOsLabel(client.os))}`, `${escapeHtml(arch.label)}`, ` ${client.monitors || 1}`, client.version ? ` v${escapeHtml(client.version)}` : "", ].filter(Boolean).join(""); const elevationBadges = [ client.isAdmin ? `` : "", client.elevation === "system" ? `` : "", client.elevation === "trustedinstaller" ? `` : "", client.notificationsMuted ? `` : "", ].join(""); article.innerHTML = `
${thumbHtml(client, { width: 290, height: 130, small: false })}
${isViewer ? "" : ``}
${statusDot(client)} ${client.online ? "Online" : "Offline"} · ${formatAgo(client.lastSeen)}
${formatPing(client.pingMs)}
${countryToFlag(client.country)} ${escapeHtml(displayName)} ${elevationBadges}
${escapeHtml(userLine)}${showHost ? ` @ ${escapeHtml(client.host)}` : ""}
${customTag ? `
${escapeHtml(customTag)}
` : ""}
${client.ip ? ` ${escapeHtml(client.ip)}` : ""} ${hwidShort ? ` ${escapeHtml(hwidShort)}` : ""}
${metaParts} ${hasGroup ? `${groupPillHtml(client)}` : ""}
${cpuShort || client.ram ? `
${cpuShort ? ` ${escapeHtml(cpuShort)}` : ""} ${client.ram ? ` ${escapeHtml(client.ram)}` : ""}
` : ""}
${isViewer ? "" : ``} ${isViewer ? "" : ``}
`; return article; } return { renderMerge, setLayout }; }