new dashboard UI

This commit is contained in:
KingKDot
2026-05-09 20:47:44 -05:00
parent d62f6f2e34
commit 2353c40832
7 changed files with 1399 additions and 192 deletions
@@ -58,7 +58,7 @@ func HandleScreenshot(ctx context.Context, env *rt.Env, cmdID string, allDisplay
} }
var buf bytes.Buffer var buf bytes.Buffer
opts := &jpeg.Options{Quality: 85} opts := &jpeg.Options{Quality: 92}
if err := jpeg.Encode(&buf, img, opts); err != nil { if err := jpeg.Encode(&buf, img, opts); err != nil {
log.Printf("screenshot: jpeg encode failed: %v", err) log.Printf("screenshot: jpeg encode failed: %v", err)
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{ return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
+7 -5
View File
@@ -21,20 +21,22 @@ function moveClientCardImmediately(msg) {
? CSS.escape(clientId) ? CSS.escape(clientId)
: clientId; : clientId;
const grid = document.getElementById("grid"); const grid = document.getElementById("grid");
const card = grid?.querySelector(`article[data-id="${selectorId}"]`); const card = grid?.querySelector(`[data-client-row][data-id="${selectorId}"]`);
if (!grid || !card) return; if (!grid || !card) return;
const parent = card.parentNode || grid;
if (msg.event === "client_online") { if (msg.event === "client_online") {
card.dataset.online = "true"; card.dataset.online = "true";
card.classList.remove("card-offline"); card.classList.remove("card-offline", "cv-offline");
grid.prepend(card); parent.prepend(card);
return; return;
} }
if (msg.event === "client_offline") { if (msg.event === "client_offline") {
card.dataset.online = "false"; card.dataset.online = "false";
card.classList.add("card-offline"); card.classList.add("card-offline", "cv-offline");
grid.appendChild(card); parent.appendChild(card);
} }
} }
+860
View File
@@ -2079,6 +2079,866 @@ tbody tr:hover {
} }
} }
/* ============================================================
CLIENT VIEWS — Variants A (rows) / B (table) / C (card wall)
============================================================ */
:root {
--cv-bg: #13161f;
--cv-bg-selected: #1a2238;
--cv-bg-hover: rgba(255, 255, 255, 0.03);
--cv-surface2: #1b2030;
--cv-border: rgba(148, 163, 184, 0.16);
--cv-border-strong: rgba(148, 163, 184, 0.32);
--cv-text: #e8edf2;
--cv-text-mute: #9aa6bd;
--cv-text-dim: #6b7488;
--cv-accent: #818cf8;
--cv-accent-bg: rgba(99, 102, 241, 0.18);
--cv-accent-border: rgba(129, 140, 248, 0.55);
--cv-danger: #ef9a9a;
--cv-warn: #fbbf24;
}
/* ── Layout toggle (segmented control) ─────────────────────── */
#layout-toggle {
gap: 2px;
}
.layout-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
border: 0;
background: transparent;
color: var(--cv-text-dim);
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.layout-toggle-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--cv-text);
}
.layout-toggle-btn.is-active {
background: var(--cv-accent-bg);
color: var(--cv-accent);
box-shadow: inset 0 0 0 1px var(--cv-accent-border);
}
.layout-toggle-btn i { font-size: 13px; }
/* ── Grid container — switches mode ────────────────────────── */
#grid.clients-grid[data-layout="rows"] {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
}
#grid.clients-grid[data-layout="table"] {
display: block;
padding: 0;
}
#grid.clients-grid[data-layout="cards"] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 14px;
padding: 8px 0;
}
/* ── Shared atoms ──────────────────────────────────────────── */
.cv-mid { color: var(--cv-text-dim); margin: 0 6px; }
.cv-mono { font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace; }
.cv-tab-num { font-variant-numeric: tabular-nums; }
.cv-warn { color: var(--cv-warn) !important; }
.cv-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: #475569;
box-shadow: 0 0 0 2px rgba(148, 163, 184, 0.12);
}
.cv-dot.is-online {
background: #22c55e;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.18), 0 0 8px rgba(34, 197, 94, 0.45);
}
.cv-dot.is-offline {
background: #64748b;
}
.cv-flag { font-size: 14px; line-height: 1; }
.cv-name {
font-size: 14px;
font-weight: 600;
color: var(--cv-text);
letter-spacing: -0.2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.cv-mini-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 10px;
border: 1px solid transparent;
}
.cv-pill-admin { background: rgba(234, 179, 8, 0.12); color: #fde68a; border-color: rgba(234, 179, 8, 0.3); }
.cv-pill-system { background: rgba(239, 68, 68, 0.12); color: #fca5a5; border-color: rgba(239, 68, 68, 0.3); }
.cv-pill-ti { background: rgba(168, 85, 247, 0.12); color: #d8b4fe; border-color: rgba(168, 85, 247, 0.3); }
.cv-group {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--gc, #64748b) 15%, transparent);
color: var(--gc, #cbd5e1);
border: 1px solid color-mix(in srgb, var(--gc, #64748b) 30%, transparent);
white-space: nowrap;
}
.cv-group-empty { color: var(--cv-text-dim); font-size: 11px; }
/* Custom checkbox */
.cv-checkbox {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
.cv-checkbox input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
z-index: 1;
}
.cv-checkbox input:disabled { cursor: not-allowed; }
.cv-checkbox-box {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1.5px solid var(--cv-border-strong);
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
color: transparent;
font-size: 9px;
transition: all 140ms ease;
}
.cv-checkbox input:checked + .cv-checkbox-box {
background: var(--cv-accent);
border-color: var(--cv-accent);
color: #fff;
}
.cv-checkbox input:disabled + .cv-checkbox-box { opacity: 0.4; }
/* Star button */
.cv-star {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
border: 0;
color: var(--cv-text-dim);
cursor: pointer;
font-size: 14px;
transition: color 120ms ease, transform 120ms ease;
}
.cv-star:hover { color: #fde68a; transform: scale(1.1); }
.cv-star.is-on { color: #fbbf24; }
/* Thumbnails */
.cv-thumb,
.cv-thumb-img {
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: #0d1018;
border: 1px solid var(--cv-border);
}
.cv-thumb-img { object-fit: cover; cursor: pointer; }
.cv-thumb { position: relative; }
.cv-faux {
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--p1) 0%, var(--p2) 55%, var(--p3) 100%);
opacity: var(--op, 1);
overflow: hidden;
}
.cv-faux-dots {
position: absolute;
top: 6px;
left: 8px;
display: flex;
gap: 4px;
}
.cv-faux-dots i {
display: inline-block;
border-radius: 50%;
background: rgba(255, 255, 255, 0.55);
}
.cv-faux-dots i:nth-child(1) { background: rgba(255, 90, 90, 0.78); }
.cv-faux-dots i:nth-child(2) { background: rgba(255, 200, 60, 0.68); }
.cv-faux-dots i:nth-child(3) { background: rgba(70, 220, 110, 0.64); }
.cv-faux-window {
position: absolute;
top: 22px;
left: 14px;
right: 14px;
bottom: 24px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
padding: 8px 8px;
display: flex;
flex-direction: column;
gap: 5px;
overflow: hidden;
}
.cv-faux-window span {
display: block;
height: 4px;
background: rgba(255, 255, 255, 0.34);
border-radius: 1px;
}
.cv-faux-window span:nth-child(2) { background: rgba(255, 255, 255, 0.26); }
.cv-faux-window span:nth-child(3) { background: rgba(255, 255, 255, 0.20); }
.cv-faux-window span:nth-child(4) { background: rgba(255, 255, 255, 0.16); }
.cv-faux-window span:nth-child(5) { background: rgba(255, 255, 255, 0.12); }
.cv-faux-bar {
position: absolute;
bottom: 4px;
left: 8px;
right: 8px;
height: 14px;
background: rgba(0, 0, 0, 0.40);
border-radius: 3px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 5px;
}
.cv-faux-bar i {
display: inline-block;
width: 8px;
height: 8px;
background: rgba(255, 255, 255, 0.36);
border-radius: 1px;
}
/* Buttons */
.cv-btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border-radius: 6px;
background: var(--cv-accent);
color: #fff;
font-size: 12px;
font-weight: 500;
border: 0;
cursor: pointer;
transition: background-color 140ms ease, transform 140ms ease;
}
.cv-btn-primary:hover { background: #6366f1; }
.cv-btn-primary i { font-size: 12px; }
.cv-btn-primary.cv-btn-sm { height: 26px; padding: 0 10px; font-size: 11px; }
.cv-btn-primary.cv-btn-flex { flex: 1; }
.cv-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--cv-surface2);
border: 1px solid var(--cv-border);
color: var(--cv-text-mute);
cursor: pointer;
font-size: 12px;
transition: all 140ms ease;
}
.cv-icon-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--cv-text);
border-color: var(--cv-border-strong);
}
.cv-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.cv-icon-btn.cv-icon-sm { width: 26px; height: 26px; font-size: 11px; }
.cv-icon-btn.cv-icon-danger { color: var(--cv-danger); border-color: rgba(239, 68, 68, 0.25); }
.cv-icon-btn.cv-icon-danger:hover { background: rgba(239, 68, 68, 0.10); border-color: rgba(239, 68, 68, 0.45); }
.cv-actions { display: inline-flex; align-items: center; gap: 4px; }
/* ════════════════════════════════════════════════════════════
VARIANT A — Row cards
════════════════════════════════════════════════════════════ */
.cv-row {
position: relative;
display: flex;
align-items: center;
gap: 16px;
background: var(--cv-bg);
border: 1px solid var(--cv-border);
border-radius: 12px;
padding: 18px 22px 18px 25px;
min-height: 116px;
flex-wrap: wrap;
transition: background-color 140ms ease, border-color 140ms ease;
}
.cv-row .cv-edge {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: var(--group-color, transparent);
border-top-left-radius: 12px;
border-bottom-left-radius: 12px;
}
.cv-row.cv-offline { opacity: 0.65; }
.cv-row.is-bookmarked { border-color: rgba(202, 138, 4, 0.5); }
.cv-row:has(.client-checkbox:checked) {
background: var(--cv-bg-selected);
border-color: var(--cv-accent-border);
}
.cv-row .cv-primary {
flex: 1 1 320px;
min-width: 240px;
display: flex;
flex-direction: column;
gap: 4px;
}
.cv-row .cv-name-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
min-width: 0;
}
.cv-row .cv-name { font-size: 16px; }
.cv-row .cv-user-line {
font-size: 12px;
font-family: ui-monospace, "SFMono-Regular", "Menlo", monospace;
color: var(--cv-text-mute);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cv-row .cv-user-line i { margin-right: 4px; opacity: 0.6; }
.cv-row .cv-meta-line {
font-size: 12px;
color: var(--cv-text-dim);
display: flex;
align-items: center;
flex-wrap: wrap;
margin-top: 4px;
}
.cv-row .cv-meta-line i { font-size: 11px; margin-right: 3px; }
.cv-row .cv-tag {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 4px;
padding: 2px 8px;
font-size: 11px;
background: rgba(245, 158, 11, 0.12);
color: #fcd34d;
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 4px;
cursor: default;
align-self: flex-start;
}
.cv-row .cv-tag.has-note { cursor: pointer; }
.cv-row .cv-tag.has-note:hover { background: rgba(245, 158, 11, 0.18); }
.cv-row .client-tag-note {
flex-basis: 100%;
margin-top: 6px;
padding: 8px 10px;
background: var(--cv-surface2);
border: 1px solid var(--cv-border);
border-radius: 6px;
font-size: 12px;
color: #fde68a;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
}
.cv-row .client-tag-note.hidden { display: none; }
.cv-row .cv-time {
flex: 0 0 120px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--cv-text-dim);
}
.cv-row .cv-time-line { display: inline-flex; align-items: center; gap: 6px; }
.cv-row .cv-time i { font-size: 11px; }
.cv-row .cv-ping-line {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: ui-monospace, monospace;
font-variant-numeric: tabular-nums;
}
.cv-row .cv-group-cell { flex: 0 0 auto; min-width: 60px; }
.cv-row .cv-spacer { flex: 1 1 auto; }
.cv-row .cv-actions { opacity: 0.85; transition: opacity 120ms ease; }
.cv-row:hover .cv-actions,
.cv-row:has(.client-checkbox:checked) .cv-actions { opacity: 1; }
.cv-row .cv-expand-btn[aria-expanded="true"] i { transform: rotate(180deg); transition: transform 180ms ease; }
.cv-row .cv-expand-btn i { transition: transform 180ms ease; }
.cv-row .cv-expand-panel {
flex-basis: 100%;
margin: 14px -22px -18px -25px;
padding: 12px 22px 16px 25px;
border-top: 1px solid var(--cv-border);
background: var(--cv-surface2);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.cv-row .cv-expand-panel.hidden { display: none; }
.cv-expand-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.cv-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.cv-field-wide { grid-column: span 2; }
.cv-field-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--cv-text-mute);
font-weight: 600;
}
.cv-field-value {
font-size: 12px;
color: var(--cv-text);
word-break: break-word;
overflow-wrap: anywhere;
}
.cv-field-value.copy-id-btn { cursor: pointer; }
.cv-field-value.copy-id-btn:hover { color: var(--cv-accent); }
/* Ping tones */
.ping-good { color: #86efac; }
.ping-mid { color: #fcd34d; }
.ping-bad { color: #fca5a5; }
.ping-unknown { color: var(--cv-text-dim); }
/* OS / arch tones — applied to icon containers across all variants */
.cv-tone-pill-win { color: #93c5fd; }
.cv-tone-pill-mac { color: #f1f5f9; }
.cv-tone-pill-ubuntu { color: #fdba74; }
.cv-tone-pill-debian { color: #f9a8d4; }
.cv-tone-pill-arch { color: #67e8f9; }
.cv-tone-pill-kali { color: #7dd3fc; }
.cv-tone-pill-fedora { color: #bfdbfe; }
.cv-tone-pill-linux { color: #86efac; }
.cv-tone-pill-x64 { color: #5eead4; }
.cv-tone-pill-arm { color: #7dd3fc; }
.cv-tone-pill-ghost,
.cv-tone-pill-unknown { color: var(--cv-text-mute); }
.cv-os, .cv-arch { display: inline-flex; align-items: center; gap: 4px; }
.cv-os i { font-size: 11px; }
/* Arch chip in table variant */
.clients-table .cv-arch-chip {
background: rgba(255, 255, 255, 0.04);
}
/* ════════════════════════════════════════════════════════════
VARIANT B — Dense table
════════════════════════════════════════════════════════════ */
.clients-table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--cv-border);
border-radius: 10px;
overflow: hidden;
background: var(--cv-bg) !important;
backdrop-filter: none;
}
.clients-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--cv-bg) !important;
color: var(--cv-text-mute);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--cv-border);
}
.clients-table th.cv-th-check { width: 36px; padding-left: 12px; }
.clients-table th.cv-th-star { width: 30px; }
.clients-table th.cv-th-thumb { width: 80px; }
.clients-table th.cv-th-status { width: 110px; }
.clients-table th.cv-th-last { width: 130px; }
.clients-table th.cv-th-system { width: 140px; }
.clients-table th.cv-th-ping { width: 90px; text-align: right; }
.clients-table th.cv-th-group { width: 90px; }
.clients-table th.cv-th-actions { width: 160px; text-align: right; padding-right: 12px; }
.clients-table tbody tr,
.clients-table tbody tr:nth-child(even) {
background: transparent;
border-bottom: 1px solid var(--cv-border);
transition: background-color 100ms ease;
}
.clients-table tbody tr:hover {
background: var(--cv-bg-hover);
}
.clients-table tbody tr:has(.client-checkbox:checked) {
background: var(--cv-bg-selected) !important;
}
.clients-table tbody tr.cv-trow.cv-offline { opacity: 0.65; }
.clients-table tbody tr.is-bookmarked td:first-child { box-shadow: inset 3px 0 0 0 rgba(202, 138, 4, 0.55); }
.clients-table td {
padding: 8px 10px;
vertical-align: middle;
border-bottom: 1px solid var(--cv-border);
color: var(--cv-text);
font-size: 13px;
}
.clients-table td.cv-td-check { padding-left: 12px; }
.clients-table td.cv-td-actions { padding-right: 12px; text-align: right; }
.clients-table td.cv-td-ping { text-align: right; }
.clients-table tbody tr:last-child td { border-bottom: 0; }
.cv-tcell-client { display: flex; align-items: center; gap: 8px; min-width: 0; }
.cv-tcell-stack { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
.cv-tcell-name-row { display: flex; align-items: center; gap: 5px; min-width: 0; }
.cv-tcell-stack .cv-name { font-size: 13px; }
.cv-tcell-stack .cv-user-line {
font-size: 11px;
color: var(--cv-text-mute);
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cv-tcell-stack .cv-user-line i { font-size: 10px; opacity: 0.7; }
.cv-status-cell { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--cv-text-dim); }
.cv-trow[data-online="true"] .cv-status-cell { color: var(--cv-text); }
.cv-td-last { color: var(--cv-text-mute); font-size: 12px; }
.cv-system-cell { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
.cv-arch-chip {
display: inline-flex;
align-items: center;
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid var(--cv-border);
color: var(--cv-text-mute);
margin-left: 2px;
}
.cv-actions-table { justify-content: flex-end; opacity: 0.55; transition: opacity 120ms ease; }
.clients-table tbody tr:hover .cv-actions-table,
.clients-table tbody tr:has(.client-checkbox:checked) .cv-actions-table { opacity: 1; }
.clients-table .cv-thumb,
.clients-table .cv-thumb-img { border-radius: 4px; }
/* ════════════════════════════════════════════════════════════
VARIANT C — Card wall
════════════════════════════════════════════════════════════ */
.cv-card {
position: relative;
display: flex;
flex-direction: column;
background: var(--cv-bg);
border: 1px solid var(--cv-border);
border-radius: 10px;
overflow: hidden;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.cv-card:hover { transform: translateY(-2px); }
.cv-card.cv-offline { opacity: 0.85; }
.cv-card.is-bookmarked { border-color: rgba(202, 138, 4, 0.5); }
.cv-card:has(.client-checkbox:checked) {
border-color: var(--cv-accent-border);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18);
}
.cv-card-header {
position: relative;
height: 130px;
overflow: hidden;
background: #0d1018;
}
.cv-card-header .cv-thumb,
.cv-card-header .cv-thumb-img {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
border-radius: 0;
border: 0;
}
.cv-card.cv-offline .cv-card-header > .cv-thumb,
.cv-card.cv-offline .cv-card-header > .cv-thumb-img { opacity: 0.3 !important; }
.cv-card-check {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
width: 18px;
height: 18px;
opacity: 0;
transition: opacity 140ms ease;
background: rgba(0, 0, 0, 0.45);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
border-radius: 4px;
padding: 0;
}
.cv-card-check .cv-checkbox-box {
border-color: rgba(255, 255, 255, 0.5);
background: transparent;
}
.cv-card:hover .cv-card-check,
.cv-card:has(.client-checkbox:checked) .cv-card-check { opacity: 1; }
.cv-card-chips {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: flex;
gap: 4px;
}
.cv-chip-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #f1f5f9;
cursor: pointer;
font-size: 11px;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
transition: background-color 140ms ease, color 140ms ease;
}
.cv-chip-btn:hover { background: rgba(0, 0, 0, 0.6); }
.cv-chip-btn.is-on { color: #fbbf24; }
.cv-card-status {
position: absolute;
bottom: 8px;
left: 8px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.08);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
color: #fff;
font-size: 11px;
}
.cv-card-status .cv-card-ago { color: #cbd2e0; }
.cv-card-ping {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.08);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
font-size: 11px;
color: #fff;
}
.cv-card-ping i { font-size: 10px; }
.cv-card {
/* let cards in the same row stretch so actions can sit on the same baseline */
}
.cv-card-body {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px 12px;
flex: 1 1 auto;
min-height: 0;
}
.cv-card-body .cv-name-line { display: flex; align-items: center; gap: 6px; min-width: 0; }
.cv-card-body .cv-name { font-size: 14px; }
.cv-card-body .cv-user-line {
font-size: 11px;
color: var(--cv-text-mute);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cv-card-body .cv-user-line i { margin-right: 4px; opacity: 0.6; }
.cv-card-net {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
font-size: 11px;
color: var(--cv-text-dim);
flex-wrap: wrap;
}
.cv-card-net:empty { display: none; }
.cv-card-net-bit { display: inline-flex; align-items: center; gap: 5px; }
.cv-card-net-bit i { font-size: 10px; opacity: 0.7; }
.cv-card-tag {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 6px;
align-self: flex-start;
padding: 2px 8px;
font-size: 11px;
background: rgba(245, 158, 11, 0.12);
color: #fcd34d;
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cv-card-meta {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
color: var(--cv-text-dim);
flex-wrap: wrap;
}
.cv-card-meta-bit { display: inline-flex; align-items: center; gap: 4px; }
.cv-card-meta-bit i { font-size: 10px; }
.cv-group-spacer { margin-left: auto; }
.cv-card-hw {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
padding: 6px 8px;
background: var(--cv-surface2);
border-radius: 6px;
font-size: 11px;
color: var(--cv-text-mute);
flex-wrap: wrap;
}
.cv-card-hw-bit {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cv-card-hw-bit i { color: var(--cv-text-dim); font-size: 10px; }
.cv-text-dim { color: var(--cv-text-dim); }
.cv-card-actions {
margin-top: auto;
padding-top: 10px;
border-top: 1px solid var(--cv-border);
display: flex;
align-items: center;
gap: 4px;
opacity: 0.85;
transition: opacity 120ms ease;
}
.cv-card-meta { margin-bottom: 6px; }
.cv-card:hover .cv-card-actions,
.cv-card:has(.client-checkbox:checked) .cv-card-actions { opacity: 1; }
/* ── Mobile tweaks ─────────────────────────────────────────── */
@media (max-width: 720px) {
.cv-row {
padding: 10px 12px 10px 15px;
gap: 10px;
}
.cv-row .cv-primary { flex-basis: 100%; }
.cv-row .cv-time, .cv-row .cv-group-cell { flex-basis: auto; }
.cv-expand-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.clients-table th.cv-th-thumb,
.clients-table td.cv-td-thumb,
.clients-table th.cv-th-system,
.clients-table td.cv-td-system { display: none; }
#grid.clients-grid[data-layout="cards"] {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
/* ============================================================ /* ============================================================
SIDEBAR LAYOUT SIDEBAR LAYOUT
============================================================ */ ============================================================ */
+32 -3
View File
@@ -115,7 +115,7 @@ function detectClientPlatform(clientId) {
typeof CSS !== "undefined" && typeof CSS.escape === "function" typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(clientId) ? CSS.escape(clientId)
: clientId; : clientId;
const card = document.querySelector(`article[data-id="${selectorId}"]`); const card = document.querySelector(`[data-client-row][data-id="${selectorId}"]`);
const os = String(card?.dataset?.os || "").toLowerCase(); const os = String(card?.dataset?.os || "").toLowerCase();
if (os.includes("windows")) return "windows"; if (os.includes("windows")) return "windows";
if (os.includes("darwin") || os.includes("mac")) return "mac"; if (os.includes("darwin") || os.includes("mac")) return "mac";
@@ -129,7 +129,7 @@ function getClientCard(clientId) {
typeof CSS !== "undefined" && typeof CSS.escape === "function" typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(clientId) ? CSS.escape(clientId)
: clientId; : clientId;
return document.querySelector(`article[data-id="${selectorId}"]`); return document.querySelector(`[data-client-row][data-id="${selectorId}"]`);
} }
window.__uninstallingClientIds = window.__uninstallingClientIds || new Set(); window.__uninstallingClientIds = window.__uninstallingClientIds || new Set();
@@ -558,8 +558,36 @@ async function loadPluginsForClient(clientId) {
} }
} }
const PREF_LAYOUT_KEY = "overlord_layout";
let rendererSetLayout = null;
function applyLayoutToggleUI(layout) {
document.querySelectorAll("#layout-toggle .layout-toggle-btn").forEach((btn) => {
const active = btn.dataset.layout === layout;
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function setLayout(layout) {
const next = ["rows", "table", "cards"].includes(layout) ? layout : "rows";
localStorage.setItem(PREF_LAYOUT_KEY, next);
applyLayoutToggleUI(next);
if (rendererSetLayout) rendererSetLayout(next);
state.lastDigest = "";
loadWithOptions({ force: true, reorder: true });
}
document.querySelectorAll("#layout-toggle .layout-toggle-btn").forEach((btn) => {
btn.addEventListener("click", () => setLayout(btn.dataset.layout));
});
function initializeRenderer() { function initializeRenderer() {
const { renderMerge } = createRenderer({ const savedLayout = localStorage.getItem(PREF_LAYOUT_KEY) || "rows";
if (grid) grid.dataset.layout = ["rows", "table", "cards"].includes(savedLayout) ? savedLayout : "rows";
applyLayoutToggleUI(grid?.dataset.layout || "rows");
const { renderMerge, setLayout: rSetLayout } = createRenderer({
grid, grid,
totalPill, totalPill,
pageLabel, pageLabel,
@@ -574,6 +602,7 @@ function initializeRenderer() {
pingClient: (id) => sendCommand(id, "ping"), pingClient: (id) => sendCommand(id, "ping"),
userRole: currentUser?.role, userRole: currentUser?.role,
}); });
rendererSetLayout = rSetLayout;
registerRenderer(renderMerge); registerRenderer(renderMerge);
refreshGroupFilter(); refreshGroupFilter();
loadWithOptions(); loadWithOptions();
+461 -179
View File
@@ -15,8 +15,102 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
function cardDigest(c) { const ROW_SELECTOR = "[data-client-row]";
return `${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.thumbnail}|${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}|${JSON.stringify(c.permissions)}|${c.groupId}|${c.groupName}|${c.groupColor}`;
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 `<span class="cv-mid" aria-hidden="true">·</span>`;
}
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 `
<div class="cv-faux" style="--p1:${palette[0]};--p2:${palette[1]};--p3:${palette[2]};--op:${op}">
<div class="cv-faux-dots">
<i style="width:${dotR}px;height:${dotR}px"></i>
<i style="width:${dotR}px;height:${dotR}px"></i>
<i style="width:${dotR}px;height:${dotR}px"></i>
</div>
<div class="cv-faux-window">
<span style="width:78%"></span>
<span style="width:62%"></span>
<span style="width:48%"></span>
<span style="width:36%"></span>
<span style="width:24%"></span>
</div>
<div class="cv-faux-bar">
<i></i><i></i><i></i><i></i><i></i>
</div>
</div>
`;
}
function thumbHtml(client, { width, height, small = false } = {}) {
if (client.thumbnail) {
return `<img class="thumb-img cv-thumb-img" alt="preview" src="${escapeHtml(client.thumbnail)}" style="width:${width}px;height:${height}px;${client.online ? "" : "opacity:0.35"}">`;
}
return `<div class="cv-thumb" style="width:${width}px;height:${height}px;${client.online ? "" : "opacity:0.35"}">${fauxDesktopHtml(client, { small })}</div>`;
}
function statusDot(client) {
return `<span class="cv-dot ${client.online ? "is-online" : "is-offline"}"></span>`;
}
function groupPillHtml(client) {
const name = String(client.groupName || "").trim();
if (!name) return "";
const color = String(client.groupColor || "").trim() || "#64748b";
return `<span class="cv-group" style="--gc:${escapeHtml(color)}">${escapeHtml(name)}</span>`;
}
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({ export function createRenderer({
@@ -37,6 +131,51 @@ export function createRenderer({
const TOUCH_MOVE_CANCEL_PX = 10; const TOUCH_MOVE_CANCEL_PX = 10;
let renderToken = 0; let renderToken = 0;
let gridDelegated = false; 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 = `
<table class="clients-table">
<thead>
<tr>
<th class="cv-th-check"></th>
<th class="cv-th-star"></th>
<th class="cv-th-thumb"></th>
<th>Client</th>
<th class="cv-th-status">Status</th>
<th class="cv-th-last">Last seen</th>
<th class="cv-th-system">System</th>
<th class="cv-th-ping">Ping</th>
<th class="cv-th-group">Group</th>
<th class="cv-th-actions">Actions</th>
</tr>
</thead>
<tbody class="clients-table-body"></tbody>
</table>
`;
} 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 ─────────────────────────────────────────── */ /* ── Event delegation ─────────────────────────────────────────── */
@@ -45,7 +184,7 @@ export function createRenderer({
gridDelegated = true; gridDelegated = true;
grid.addEventListener("click", (e) => { grid.addEventListener("click", (e) => {
const card = e.target.closest("article[data-id]"); const card = e.target.closest(ROW_SELECTOR);
if (!card) return; if (!card) return;
const clientId = card.dataset.id; const clientId = card.dataset.id;
@@ -88,6 +227,19 @@ export function createRenderer({
return; 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")) { if (e.target.closest(".client-tag-toggle")) {
e.stopPropagation(); e.stopPropagation();
handleTagToggle(card); handleTagToggle(card);
@@ -100,6 +252,12 @@ export function createRenderer({
return; return;
} }
if (e.target.closest(".cv-expand-btn")) {
e.stopPropagation();
handleExpandToggle(card);
return;
}
const thumbImg = e.target.closest(".thumb-img"); const thumbImg = e.target.closest(".thumb-img");
if (thumbImg) { if (thumbImg) {
if (thumbImg.src) openModal(thumbImg.src); if (thumbImg.src) openModal(thumbImg.src);
@@ -128,7 +286,7 @@ export function createRenderer({
}); });
grid.addEventListener("contextmenu", (e) => { grid.addEventListener("contextmenu", (e) => {
const card = e.target.closest("article[data-id]"); const card = e.target.closest(ROW_SELECTOR);
if (!card || isViewer) return; if (!card || isViewer) return;
e.preventDefault(); e.preventDefault();
openMenu(card.dataset.id, e.clientX, e.clientY); openMenu(card.dataset.id, e.clientX, e.clientY);
@@ -137,7 +295,7 @@ export function createRenderer({
grid.addEventListener("pointerdown", (e) => { grid.addEventListener("pointerdown", (e) => {
if (isViewer || e.pointerType !== "touch") return; if (isViewer || e.pointerType !== "touch") return;
if (e.target.closest("button") || e.target.closest(".client-checkbox")) return; if (e.target.closest("button") || e.target.closest(".client-checkbox")) return;
const card = e.target.closest("article[data-id]"); const card = e.target.closest(ROW_SELECTOR);
if (!card) return; if (!card) return;
card._longPressTriggered = false; card._longPressTriggered = false;
card._pointerStartX = e.clientX; card._pointerStartX = e.clientX;
@@ -151,7 +309,7 @@ export function createRenderer({
grid.addEventListener("pointermove", (e) => { grid.addEventListener("pointermove", (e) => {
if (e.pointerType !== "touch") return; if (e.pointerType !== "touch") return;
const card = e.target.closest("article[data-id]"); const card = e.target.closest(ROW_SELECTOR);
if (!card || !card._longPressTimer) return; if (!card || !card._longPressTimer) return;
if ( if (
Math.abs(e.clientX - card._pointerStartX) > TOUCH_MOVE_CANCEL_PX || Math.abs(e.clientX - card._pointerStartX) > TOUCH_MOVE_CANCEL_PX ||
@@ -164,7 +322,7 @@ export function createRenderer({
const clearLongPress = (e) => { const clearLongPress = (e) => {
if (e.pointerType !== "touch") return; if (e.pointerType !== "touch") return;
const card = e.target.closest("article[data-id]"); const card = e.target.closest(ROW_SELECTOR);
if (card) { clearTimeout(card._longPressTimer); card._longPressTimer = null; } if (card) { clearTimeout(card._longPressTimer); card._longPressTimer = null; }
}; };
grid.addEventListener("pointerup", clearLongPress); grid.addEventListener("pointerup", clearLongPress);
@@ -184,21 +342,12 @@ export function createRenderer({
if (res.ok) { if (res.ok) {
card.dataset.bookmarked = String(!isBookmarked); card.dataset.bookmarked = String(!isBookmarked);
const icon = btn.querySelector("i"); const icon = btn.querySelector("i");
if (!isBookmarked) { if (icon) {
btn.classList.remove("border-slate-700", "bg-slate-800/50", "text-slate-500"); icon.classList.toggle("fa-solid", !isBookmarked);
btn.classList.add("border-yellow-600", "bg-yellow-900/50", "text-yellow-300"); icon.classList.toggle("fa-regular", isBookmarked);
card.classList.remove("border-slate-800");
card.classList.add("border-yellow-600/60");
if (icon) { icon.classList.remove("fa-regular"); icon.classList.add("fa-solid"); }
btn.title = "Remove bookmark";
} else {
btn.classList.remove("border-yellow-600", "bg-yellow-900/50", "text-yellow-300");
btn.classList.add("border-slate-700", "bg-slate-800/50", "text-slate-500");
card.classList.remove("border-yellow-600/60");
card.classList.add("border-slate-800");
if (icon) { icon.classList.remove("fa-solid"); icon.classList.add("fa-regular"); }
btn.title = "Bookmark";
} }
btn.classList.toggle("is-on", !isBookmarked);
btn.title = !isBookmarked ? "Remove bookmark" : "Bookmark";
} }
} catch (err) { } catch (err) {
console.error("bookmark toggle failed", err); console.error("bookmark toggle failed", err);
@@ -233,9 +382,18 @@ export function createRenderer({
} }
} }
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) { function reorderCards(items) {
const cards = grid.querySelectorAll("article[data-id]"); const container = getCardContainer();
const cards = container.querySelectorAll(ROW_SELECTOR);
let needsReorder = cards.length !== items.length; let needsReorder = cards.length !== items.length;
if (!needsReorder) { if (!needsReorder) {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
@@ -244,14 +402,19 @@ export function createRenderer({
} }
if (!needsReorder) return; if (!needsReorder) return;
items.forEach((client) => { items.forEach((client) => {
const card = grid.querySelector(`article[data-id="${client.id}"]`); const card = container.querySelector(`${ROW_SELECTOR}[data-id="${client.id}"]`);
if (card) grid.appendChild(card); 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.thumbnail}|${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}`;
}
function renderMerge(data, options = {}) { function renderMerge(data, options = {}) {
setupGridDelegation(); setupGridDelegation();
ensureLayoutScaffold();
const container = getCardContainer();
const { reorder = false } = options; const { reorder = false } = options;
totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`; totalPill.textContent = `${data.online ?? data.total} online / ${data.total} total`;
const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize)); const totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
@@ -267,14 +430,14 @@ export function createRenderer({
window.matchMedia && window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches; window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const hadCards = grid.querySelectorAll("article[data-id]").length > 0; const hadCards = container.querySelectorAll(ROW_SELECTOR).length > 0;
const uninstallingIds = window.__uninstallingClientIds; const uninstallingIds = window.__uninstallingClientIds;
items.forEach((client) => { items.forEach((client) => {
seen.add(client.id); seen.add(client.id);
if (uninstallingIds && uninstallingIds.has(client.id)) return; if (uninstallingIds && uninstallingIds.has(client.id)) return;
const existing = grid.querySelector(`article[data-id="${client.id}"]`); const existing = container.querySelector(`${ROW_SELECTOR}[data-id="${client.id}"]`);
if (existing) { if (existing) {
if (existing.classList.contains("card-uninstalling")) return; if (existing.classList.contains("card-uninstalling")) return;
const digest = cardDigest(client); const digest = cardDigest(client);
@@ -286,7 +449,7 @@ export function createRenderer({
newClients.push(client); newClients.push(client);
}); });
Array.from(grid.querySelectorAll("article[data-id]")) Array.from(container.querySelectorAll(ROW_SELECTOR))
.filter((el) => !seen.has(el.dataset.id) && !el.classList.contains("card-uninstalling")) .filter((el) => !seen.has(el.dataset.id) && !el.classList.contains("card-uninstalling"))
.forEach((el) => el.remove()); .forEach((el) => el.remove());
@@ -294,11 +457,9 @@ export function createRenderer({
reorderCards(items); reorderCards(items);
} }
if (newClients.length === 0) { if (newClients.length === 0) return;
return;
}
const allowAnimation = !hadCards && !prefersReducedMotion && items.length <= 1000; const allowAnimation = !hadCards && !prefersReducedMotion && items.length <= 1000 && currentLayout !== "table";
const animateLimit = Math.min(newClients.length, MAX_ANIMATED_CARDS); const animateLimit = Math.min(newClients.length, MAX_ANIMATED_CARDS);
let idx = 0; let idx = 0;
@@ -318,7 +479,7 @@ export function createRenderer({
}); });
fragment.appendChild(card); fragment.appendChild(card);
} }
grid.appendChild(fragment); container.appendChild(fragment);
if (idx < newClients.length) { if (idx < newClients.length) {
requestAnimationFrame(insertBatch); requestAnimationFrame(insertBatch);
return; return;
@@ -336,23 +497,46 @@ export function createRenderer({
if (nextBtn) nextBtn.disabled = currentPage >= totalPages; 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.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 = {}) { function buildCard(client, options = {}) {
const card = document.createElement("article"); const node =
card.dataset.id = client.id; currentLayout === "table"
card.dataset.hwid = client.hwid || ""; ? buildRowB(client, options)
: currentLayout === "cards"
? buildCardC(client, options)
: buildRowA(client, options);
updateCard(card, client); node._cardDigest = cardDigest(client);
card._cardDigest = cardDigest(client);
if (options.animate) { if (options.animate && currentLayout !== "table") {
card.classList.add("card-animate"); node.classList.add("card-animate");
card.style.animationDelay = `${(options.delayIndex || 0) * 0.05}s`; node.style.animationDelay = `${(options.delayIndex || 0) * 0.05}s`;
card.style.opacity = "0"; node.style.opacity = "0";
card.style.transform = "translateY(10px)"; node.style.transform = "translateY(10px)";
if (typeof anime !== "undefined") { if (typeof anime !== "undefined") {
requestAnimationFrame(() => { requestAnimationFrame(() => {
anime({ anime({
targets: card, targets: node,
opacity: [0, 1], opacity: [0, 1],
translateY: [10, 0], translateY: [10, 0],
duration: 400, duration: 400,
@@ -362,25 +546,42 @@ export function createRenderer({
} }
} }
return card; return node;
} }
function updateCard(card, client) { function updateCard(card, client) {
const oldCheckbox = card.querySelector(".client-checkbox"); const wasChecked = card.querySelector(".client-checkbox")?.checked || false;
const wasChecked = oldCheckbox?.checked || false;
const wasTagNoteExpanded = card.dataset.tagNoteExpanded === "true"; const wasTagNoteExpanded = card.dataset.tagNoteExpanded === "true";
const wasHwExpanded = card.dataset.hwExpanded === "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);
card.dataset.online = String(!!client.online);
card.dataset.os = String(client.os || "").toLowerCase();
card.dataset.nickname = String(client.nickname || "");
card.dataset.customTag = String(client.customTag || "");
card.dataset.bookmarked = String(!!client.bookmarked);
card.dataset.admin = String(!!client.isAdmin);
card.dataset.groupId = String(client.groupId || "");
card.dataset.groupName = String(client.groupName || "");
card.dataset.groupColor = String(client.groupColor || "");
card._customTagNote = String(client.customTagNote || "");
const os = osBadge(client.os || "unknown"); const os = osBadge(client.os || "unknown");
const arch = archBadge(client.arch || ""); const arch = archBadge(client.arch || "");
const ver = versionBadge(client.version || ""); const ver = versionBadge(client.version || "");
@@ -391,136 +592,217 @@ export function createRenderer({
const customTag = String(client.customTag || "").trim(); const customTag = String(client.customTag || "").trim();
const customTagNote = String(client.customTagNote || ""); const customTagNote = String(client.customTagNote || "");
const displayName = nickname || client.host || deviceId; const displayName = nickname || client.host || deviceId;
const userLine = client.user || client.host || deviceId;
const hasTagNote = customTag.length > 0 && customTagNote.length > 0; const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded;
card.dataset.tagNoteExpanded = isTagNoteExpanded ? "true" : "false";
const groupName = String(client.groupName || "").trim();
const groupColor = String(client.groupColor || "").trim();
const hasHwInfo = !!(client.cpu || client.gpu || client.ram); const hasHwInfo = !!(client.cpu || client.gpu || client.ram);
const isHwExpanded = hasHwInfo && wasHwExpanded; const verLatest = String(client.version || "").startsWith("2.0.");
card.dataset.hwExpanded = isHwExpanded ? "true" : "false";
const dedupeGpu = (raw) => {
if (!raw) return null;
const counts = new Map();
raw.split(",").map(s => s.trim()).filter(Boolean).forEach(g => counts.set(g, (counts.get(g) || 0) + 1));
return [...counts.entries()].map(([name, n]) => n > 1 ? `${name} <span class="hw-gpu-count">&times;${n}</span>` : escapeHtml(name)).join(", ");
};
const gpuHtml = dedupeGpu(client.gpu);
const borderClass = client.bookmarked ? "border-yellow-600/60" : "border-slate-800";
card.className = `card rounded-xl border ${borderClass} p-4 ${client.online ? "" : "card-offline"}`;
if (!client.bookmarked && groupColor) {
card.style.setProperty("--group-color", groupColor);
} else {
card.style.removeProperty("--group-color");
}
const cardThumb = client.thumbnail
? (() => {
const wrapper = document.createElement("div");
wrapper.className = "flex-shrink-0";
const img = document.createElement("img");
img.className =
"w-40 h-24 rounded-lg object-contain cursor-pointer thumb-img";
img.alt = "preview";
img.src = client.thumbnail;
wrapper.appendChild(img);
return wrapper;
})()
: (() => {
const wrapper = document.createElement("div");
wrapper.className = "flex-shrink-0";
wrapper.innerHTML = `<div class="w-40 h-24 rounded-lg border border-dashed border-slate-700 bg-slate-800/40 flex items-center justify-center text-slate-500"><i class="fa-regular fa-image"></i></div>`;
return wrapper;
})();
card.innerHTML = ` const metaParts = [
<div class="flex items-center gap-4 flex-wrap"> `<span class="cv-os cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>`,
<div class="flex-shrink-0 flex items-center"> `<span class="cv-arch cv-tone-${arch.tone}">${escapeHtml(arch.label)}</span>`,
<input type="checkbox" class="client-checkbox w-5 h-5 rounded border-slate-600 bg-slate-800 checked:bg-blue-600" data-id="${escapeHtml(client.id)}" ${client.online ? "" : "disabled"}> `<span class="cv-ver"><i class="fa ${ver.icon}"></i> ${escapeHtml(ver.label)}</span>`,
`<span class="cv-mons"><i class="fa fa-display"></i> ${client.monitors || 1}</span>`,
nickname && client.host && nickname !== client.host ? `<span class="cv-host"><i class="fa-solid fa-laptop"></i> ${escapeHtml(client.host)}</span>` : "",
client.ip ? `<span class="cv-ip cv-mono"><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}</span>` : "",
hwid ? `<span class="cv-hwid cv-mono" title="HWID ${escapeHtml(client.hwid || "")}"><i class="fa-solid fa-fingerprint"></i> ${escapeHtml(hwid)}</span>` : "",
].filter(Boolean);
const meta = metaParts.join(metaSeparator());
article.innerHTML = `
<span class="cv-edge"></span>
<label class="cv-checkbox">
<input type="checkbox" class="client-checkbox" data-id="${escapeHtml(client.id)}" ${client.online ? "" : "disabled"}>
<span class="cv-checkbox-box"><i class="fa-solid fa-check"></i></span>
</label>
<button class="bookmark-btn cv-star ${client.bookmarked ? "is-on" : ""}" data-id="${escapeHtml(client.id)}" title="${client.bookmarked ? "Remove bookmark" : "Bookmark"}">
<i class="fa-${client.bookmarked ? "solid" : "regular"} fa-star"></i>
</button>
<div class="cv-thumb-wrap">${thumbHtml(client, { width: 126, height: 80, small: false })}</div>
<div class="cv-primary">
<div class="cv-name-line">
${statusDot(client)}
<span class="cv-flag">${countryToFlag(client.country)}</span>
<span class="cv-name">${escapeHtml(displayName)}</span>
${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""}
${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""}
${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : ""}
</div> </div>
<div class="flex-shrink-0"></div> <div class="cv-user-line"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}</div>
<div class="flex-1 min-w-[240px] flex flex-col gap-2"> <div class="cv-meta-line">${meta}</div>
<div class="flex items-center gap-3 flex-wrap text-lg font-semibold"> ${customTag ? `<button type="button" class="client-tag-toggle cv-tag ${hasTagNote ? "has-note" : ""}" ${hasTagNote ? `aria-expanded="false"` : `disabled aria-disabled="true"`}><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)}${hasTagNote ? ` <i class="fa-solid fa-chevron-down"></i>` : ""}</button>` : ""}
<span class="text-2xl">${countryToFlag(client.country)}</span> ${hasTagNote ? `<div class="client-tag-note hidden">${escapeHtml(customTagNote)}</div>` : ""}
<span>${escapeHtml(displayName)}</span> </div>
${nickname && client.host ? `<span class="pill pill-ghost text-xs"><i class="fa-solid fa-laptop"></i> ${escapeHtml(client.host)}</span>` : ""} <div class="cv-time">
${customTag ? `<button type="button" class="client-tag-toggle pill text-xs border border-amber-700/80 bg-amber-900/30 text-amber-200 ${hasTagNote ? "cursor-pointer hover:bg-amber-800/40" : "cursor-default opacity-90"}" ${hasTagNote ? `aria-expanded="${isTagNoteExpanded ? "true" : "false"}"` : `disabled aria-disabled="true"`}><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)} ${customTagNote ? `<i class="fa-regular fa-note-sticky"></i><i class="fa-solid ${isTagNoteExpanded ? "fa-chevron-up" : "fa-chevron-down"}"></i>` : ""}</button>` : ""} <span class="cv-time-line"><i class="fa-regular fa-clock"></i> ${formatAgo(client.lastSeen)}</span>
${groupName ? `<span class="pill text-xs border" style="border-color:${escapeHtml(groupColor)};background:${escapeHtml(groupColor)}22;color:${escapeHtml(groupColor)}"><i class="fa-solid fa-layer-group"></i> ${escapeHtml(groupName)}</span>` : ""} <span class="cv-ping-line ${pingTone(client.pingMs)}"><i class="fa-solid fa-satellite-dish"></i> ${formatPing(client.pingMs)}</span>
<span class="text-slate-300 text-lg font-semibold flex items-center gap-1"><i class="fa-solid fa-user"></i> ${escapeHtml(client.user || "unknown")}</span> </div>
<span class="pill ${client.online ? "pill-online" : "pill-offline"}"> <div class="cv-group-cell">${groupPillHtml(client) || `<span class="cv-group-empty">—</span>`}</div>
<i class="fa-solid fa-circle"></i> <div class="cv-spacer"></div>
${client.online ? "Online" : "Offline"} <div class="cv-actions">
</span> ${isViewer ? "" : `<button class="command-btn cv-btn-primary" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
${client.isAdmin ? `<span class="pill pill-admin"><i class="fa-solid fa-shield-halved"></i> Admin</span>` : ""} <button class="cv-icon-btn cv-ping-btn" title="Ping" ${client.online ? "" : "disabled"}><i class="fa-solid fa-satellite-dish"></i></button>
${client.elevation === "system" ? `<span class="pill pill-system"><i class="fa-solid fa-gear"></i> SYSTEM</span>` : ""} ${isViewer ? "" : `<button class="cv-icon-btn cv-icon-danger ban-btn" title="Ban IP" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i></button>`}
${client.elevation === "trustedinstaller" ? `<span class="pill pill-ti"><i class="fa-solid fa-lock"></i> TrustedInstaller</span>` : ""} <button class="cv-icon-btn cv-expand-btn" title="More info" aria-expanded="false"><i class="fa-solid fa-chevron-down"></i></button>
${client.os === "darwin" && client.permissions ? (() => { </div>
const p = client.permissions; <div class="cv-expand-panel hidden">
const pills = []; <div class="cv-expand-grid">
if (p.screenRecording === true) pills.push('<span class="pill pill-perm-ok"><i class="fa-solid fa-video"></i> Screen</span>'); <div class="cv-field"><span class="cv-field-label">ID</span><span class="cv-field-value cv-mono copy-id-btn" data-copy="${escapeHtml(client.id)}" title="Copy full ID">${escapeHtml(deviceId)} <i class="fa-regular fa-copy copy-id-icon"></i></span></div>
else pills.push('<span class="pill pill-perm-no"><i class="fa-solid fa-video-slash"></i> No Screen</span>'); <div class="cv-field"><span class="cv-field-label">Hardware ID</span><span class="cv-field-value cv-mono">${escapeHtml(hwid || "—")}</span></div>
if (p.accessibility === true) pills.push('<span class="pill pill-perm-ok"><i class="fa-solid fa-universal-access"></i> Accessibility</span>'); <div class="cv-field"><span class="cv-field-label">IP</span><span class="cv-field-value cv-mono">${escapeHtml(client.ip || "—")}</span></div>
else pills.push('<span class="pill pill-perm-no"><i class="fa-solid fa-ban"></i> No Accessibility</span>'); <div class="cv-field"><span class="cv-field-label">OS</span><span class="cv-field-value">${escapeHtml(client.os || "Unknown")} ${escapeHtml(arch.label)}</span></div>
if (p.fullDiskAccess === true) pills.push('<span class="pill pill-perm-ok"><i class="fa-solid fa-hard-drive"></i> FDA</span>'); <div class="cv-field"><span class="cv-field-label">CPU</span><span class="cv-field-value">${escapeHtml(client.cpu || "—")}</span></div>
else pills.push('<span class="pill pill-perm-no"><i class="fa-solid fa-lock"></i> No FDA</span>'); <div class="cv-field"><span class="cv-field-label">RAM</span><span class="cv-field-value">${escapeHtml(client.ram || "—")}</span></div>
return pills.join(""); ${client.gpu ? `<div class="cv-field cv-field-wide"><span class="cv-field-label">GPU</span><span class="cv-field-value">${escapeHtml(client.gpu)}</span></div>` : ""}
})() : ""} ${!verLatest && client.version ? `<div class="cv-field"><span class="cv-field-label">Version</span><span class="cv-field-value cv-warn">v${escapeHtml(client.version)} (outdated)</span></div>` : ""}
${!client.online && client.disconnectReason && client.disconnectReason !== "normal" ? (() => {
const iconMap = { panic: "fa-skull-crossbones", crash: "fa-skull", timeout: "fa-clock", network: "fa-plug-circle-xmark" };
const colorMap = { panic: "text-red-400", crash: "text-red-400", timeout: "text-amber-400", network: "text-slate-400" };
const icon = iconMap[client.disconnectReason] || "fa-circle-exclamation";
const color = colorMap[client.disconnectReason] || "text-slate-400";
const detail = client.disconnectDetail ? escapeHtml(client.disconnectDetail) : "";
return `<span class="pill pill-ghost text-xs ${color}" ${detail ? `title="${detail}"` : ""}><i class="fa-solid ${icon}"></i> ${escapeHtml(client.disconnectReason)}</span>`;
})() : ""}
</div>
${hasTagNote ? `<div class="client-tag-note rounded-lg border border-amber-900/60 bg-amber-950/20 px-3 py-2 text-sm text-amber-100 whitespace-pre-wrap break-words max-h-48 overflow-auto ${isTagNoteExpanded ? "" : "hidden"}">${escapeHtml(customTagNote)}</div>` : ""}
<div class="flex items-center gap-2 flex-wrap text-sm text-slate-300">
<span class="pill pill-ghost"><i class="fa-regular fa-clock"></i> ${formatAgo(client.lastSeen)}</span>
${hasHwInfo ? `<button type="button" class="hw-toggle pill pill-hw cursor-pointer" aria-expanded="${isHwExpanded ? "true" : "false"}"><i class="fa-solid fa-microchip"></i> Hardware <i class="fa-solid ${isHwExpanded ? "fa-chevron-up" : "fa-chevron-down"}"></i></button>` : ""}
<span class="pill ${os.tone}"><i class="fa ${os.icon}"></i> ${os.label}</span>
<span class="pill ${arch.tone}"><i class="fa ${arch.icon}"></i> ${arch.label}</span>
<span class="pill ${ver.tone}"><i class="fa ${ver.icon}"></i> ${ver.label}</span>
<span class="pill ${mons.tone}"><i class="fa ${mons.icon}"></i> ${mons.label}</span>
</div>
${hasHwInfo ? `<div class="hw-panel rounded-lg border border-indigo-900/50 bg-indigo-950/20 px-3 py-2 text-sm text-slate-200 ${isHwExpanded ? "" : "hidden"}">
<div class="hw-panel-grid">
${client.cpu ? `<div class="hw-row"><span class="hw-label hw-label-cpu"><i class="fa-solid fa-microchip"></i> CPU</span><span class="hw-value">${escapeHtml(client.cpu)}</span></div>` : ""}
${gpuHtml ? `<div class="hw-row"><span class="hw-label hw-label-gpu"><i class="fa-solid fa-display"></i> GPU</span><span class="hw-value">${gpuHtml}</span></div>` : ""}
${client.ram ? `<div class="hw-row"><span class="hw-label hw-label-ram"><i class="fa-solid fa-memory"></i> RAM</span><span class="hw-value">${escapeHtml(client.ram)}</span></div>` : ""}
</div>
</div>` : ""}
<div class="flex items-center gap-2 flex-wrap text-xs text-slate-400 font-mono">
<span class="pill pill-ghost copy-id-btn cursor-pointer hover:bg-slate-700 transition-colors" data-copy="${escapeHtml(client.id)}" title="Copy full ID">ID ${deviceId} <i class="fa-regular fa-copy copy-id-icon"></i></span>
${client.hwid ? `<span class="pill pill-ghost">HW ${hwid}</span>` : ""}
${client.ip ? `<span class="pill pill-ghost"><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}</span>` : ""}
</div>
</div>
<div class="flex items-center gap-3">
<button class="bookmark-btn inline-flex items-center justify-center w-9 h-9 rounded-lg border ${client.bookmarked ? "border-yellow-600 bg-yellow-900/50 text-yellow-300" : "border-slate-700 bg-slate-800/50 text-slate-500 hover:text-yellow-300 hover:border-yellow-700"} transition-colors" data-id="${escapeHtml(client.id)}" title="${client.bookmarked ? "Remove bookmark" : "Bookmark"}"><i class="fa-${client.bookmarked ? "solid" : "regular"} fa-star"></i></button>
<span class="text-emerald-300 font-mono text-sm inline-flex items-center gap-2"><i class="fa-solid fa-satellite-dish"></i> ${formatPing(client.pingMs)}</span>
${isViewer ? "" : `<button class="command-btn inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-700/60 bg-slate-800/70 hover:bg-slate-700/70" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-bars"></i> Commands</button>`}
${isViewer ? "" : `<button class="ban-btn inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-red-900/60 bg-red-950/40 hover:bg-red-900/50 text-red-300" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i> Ban</button>`}
</div> </div>
</div> </div>
`; `;
return article;
const thumbSlots = card.querySelectorAll(".flex-shrink-0");
if (thumbSlots.length >= 2) {
thumbSlots[1].replaceWith(cardThumb);
}
const checkbox = card.querySelector(".client-checkbox");
if (checkbox) {
const isSelected =
typeof window.isClientSelected === "function"
? window.isClientSelected(client.id)
: false;
if ((wasChecked || isSelected) && client.online) {
checkbox.checked = true;
}
}
} }
return { renderMerge }; /* ── 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 = `
<td class="cv-td-check">
<label class="cv-checkbox">
<input type="checkbox" class="client-checkbox" data-id="${escapeHtml(client.id)}" ${client.online ? "" : "disabled"}>
<span class="cv-checkbox-box"><i class="fa-solid fa-check"></i></span>
</label>
</td>
<td class="cv-td-star">
<button class="bookmark-btn cv-star ${client.bookmarked ? "is-on" : ""}" data-id="${escapeHtml(client.id)}" title="${client.bookmarked ? "Remove bookmark" : "Bookmark"}">
<i class="fa-${client.bookmarked ? "solid" : "regular"} fa-star"></i>
</button>
</td>
<td class="cv-td-thumb">${thumbHtml(client, { width: 56, height: 35, small: true })}</td>
<td class="cv-td-client">
<div class="cv-tcell-client">
<span class="cv-flag">${countryToFlag(client.country)}</span>
<div class="cv-tcell-stack">
<span class="cv-tcell-name-row">
<span class="cv-name">${escapeHtml(displayName)}</span>
${client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : ""}
${client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : ""}
${client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TI"><i class="fa-solid fa-lock"></i></span>` : ""}
</span>
<span class="cv-user-line cv-mono"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}${client.ip ? `<span class="cv-mid">·</span><i class="fa-solid fa-network-wired"></i> ${escapeHtml(client.ip)}` : ""}</span>
</div>
</div>
</td>
<td class="cv-td-status">
<span class="cv-status-cell">${statusDot(client)} ${client.online ? "Online" : "Offline"}</span>
</td>
<td class="cv-td-last cv-tab-num">${formatAgo(client.lastSeen)}</td>
<td class="cv-td-system">
<span class="cv-system-cell"><span class="cv-os cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span> <span class="cv-arch-chip cv-tone-${arch.tone}">${escapeHtml(arch.label)}</span></span>
</td>
<td class="cv-td-ping cv-tab-num cv-mono ${pingTone(client.pingMs)}">${formatPing(client.pingMs)}</td>
<td class="cv-td-group">${groupPillHtml(client) || `<span class="cv-group-empty">—</span>`}</td>
<td class="cv-td-actions">
<div class="cv-actions cv-actions-table">
${isViewer ? "" : `<button class="command-btn cv-btn-primary cv-btn-sm" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
${isViewer ? "" : `<button class="cv-icon-btn cv-icon-sm kebab-btn" title="More" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ellipsis-vertical"></i></button>`}
</div>
</td>
`;
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 = [
`<span class="cv-card-meta-bit cv-tone-${os.tone}"><i class="fa ${os.icon}"></i> ${escapeHtml(shortOsLabel(client.os))}</span>`,
`<span class="cv-card-meta-bit cv-tone-${arch.tone}">${escapeHtml(arch.label)}</span>`,
`<span class="cv-card-meta-bit"><i class="fa fa-display"></i> ${client.monitors || 1}</span>`,
client.version ? `<span class="cv-card-meta-bit ${verLatest ? "" : "cv-warn"}"><i class="fa fa-tag"></i> v${escapeHtml(client.version)}</span>` : "",
].filter(Boolean).join("");
const elevationBadges = [
client.isAdmin ? `<span class="cv-mini-pill cv-pill-admin" title="Admin"><i class="fa-solid fa-shield-halved"></i></span>` : "",
client.elevation === "system" ? `<span class="cv-mini-pill cv-pill-system" title="SYSTEM"><i class="fa-solid fa-gear"></i></span>` : "",
client.elevation === "trustedinstaller" ? `<span class="cv-mini-pill cv-pill-ti" title="TrustedInstaller"><i class="fa-solid fa-lock"></i></span>` : "",
].join("");
article.innerHTML = `
<header class="cv-card-header">
${thumbHtml(client, { width: 290, height: 130, small: false })}
<label class="cv-checkbox cv-card-check">
<input type="checkbox" class="client-checkbox" data-id="${escapeHtml(client.id)}" ${client.online ? "" : "disabled"}>
<span class="cv-checkbox-box"><i class="fa-solid fa-check"></i></span>
</label>
<div class="cv-card-chips">
<button class="bookmark-btn cv-chip-btn ${client.bookmarked ? "is-on" : ""}" data-id="${escapeHtml(client.id)}" title="${client.bookmarked ? "Remove bookmark" : "Bookmark"}">
<i class="fa-${client.bookmarked ? "solid" : "regular"} fa-star"></i>
</button>
${isViewer ? "" : `<button class="cv-chip-btn kebab-btn" title="More" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ellipsis-vertical"></i></button>`}
</div>
<div class="cv-card-status">
${statusDot(client)} <span>${client.online ? "Online" : "Offline"}</span> <span class="cv-mid">·</span> <span class="cv-card-ago">${formatAgo(client.lastSeen)}</span>
</div>
<div class="cv-card-ping ${pingTone(client.pingMs)}">
<i class="fa-solid fa-satellite-dish"></i> <span class="cv-mono">${formatPing(client.pingMs)}</span>
</div>
</header>
<div class="cv-card-body">
<div class="cv-name-line">
<span class="cv-flag">${countryToFlag(client.country)}</span>
<span class="cv-name">${escapeHtml(displayName)}</span>
${elevationBadges}
</div>
<div class="cv-user-line cv-mono"><i class="fa-solid fa-user"></i> ${escapeHtml(userLine)}${showHost ? ` <span class="cv-text-dim">@ ${escapeHtml(client.host)}</span>` : ""}</div>
${customTag ? `<div class="cv-card-tag"><i class="fa-solid fa-tag"></i> ${escapeHtml(customTag)}</div>` : ""}
<div class="cv-card-net">
${client.ip ? `<span class="cv-card-net-bit"><i class="fa-solid fa-network-wired"></i> <span class="cv-mono">${escapeHtml(client.ip)}</span></span>` : ""}
${hwidShort ? `<span class="cv-card-net-bit"><i class="fa-solid fa-fingerprint"></i> <span class="cv-mono">${escapeHtml(hwidShort)}</span></span>` : ""}
</div>
<div class="cv-card-meta">
${metaParts}
${hasGroup ? `<span class="cv-group-spacer">${groupPillHtml(client)}</span>` : ""}
</div>
${cpuShort || client.ram ? `<div class="cv-card-hw">
${cpuShort ? `<span class="cv-card-hw-bit" title="${escapeHtml(client.cpu)}"><i class="fa-solid fa-microchip"></i> ${escapeHtml(cpuShort)}</span>` : ""}
${client.ram ? `<span class="cv-card-hw-bit"><i class="fa-solid fa-memory"></i> ${escapeHtml(client.ram)}</span>` : ""}
</div>` : ""}
<div class="cv-card-actions">
${isViewer ? "" : `<button class="command-btn cv-btn-primary cv-btn-flex" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-terminal"></i><span>Commands</span></button>`}
<button class="cv-icon-btn cv-ping-btn" title="Ping" ${client.online ? "" : "disabled"}><i class="fa-solid fa-satellite-dish"></i></button>
${isViewer ? "" : `<button class="cv-icon-btn cv-icon-danger ban-btn" title="Ban IP" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i></button>`}
</div>
</div>
`;
return article;
}
return { renderMerge, setLayout };
} }
+35 -1
View File
@@ -111,6 +111,40 @@
> >
Clear selection Clear selection
</button> </button>
<div
id="layout-toggle"
role="group"
aria-label="Layout"
class="ml-auto inline-flex items-center rounded-lg border border-slate-700/60 bg-slate-800/70 p-0.5"
>
<button
type="button"
data-layout="rows"
class="layout-toggle-btn"
title="Row cards"
aria-pressed="true"
>
<i class="fa-solid fa-list"></i>
</button>
<button
type="button"
data-layout="table"
class="layout-toggle-btn"
title="Dense table"
aria-pressed="false"
>
<i class="fa-solid fa-table-list"></i>
</button>
<button
type="button"
data-layout="cards"
class="layout-toggle-btn"
title="Card wall"
aria-pressed="false"
>
<i class="fa-solid fa-grip"></i>
</button>
</div>
</div> </div>
<!-- Bulk Actions Toolbar --> <!-- Bulk Actions Toolbar -->
<div <div
@@ -156,7 +190,7 @@
</button> </button>
</div> </div>
</div> </div>
<div id="grid" class="flex flex-col gap-3 min-h-[420px]"></div> <div id="grid" class="clients-grid min-h-[420px]" data-layout="rows"></div>
<div id="pagination" class="flex items-center justify-center gap-3 py-4" style="visibility:hidden"> <div id="pagination" class="flex items-center justify-center gap-3 py-4" style="visibility:hidden">
<button <button
id="prev" id="prev"
+3 -3
View File
@@ -14,9 +14,9 @@ async function getSharp(): Promise<any> {
} }
} }
const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 640)); const THUMBNAIL_WIDTH = Math.max(64, Number(process.env.OVERLORD_THUMBNAIL_WIDTH || 1920));
const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 360)); const THUMBNAIL_HEIGHT = Math.max(48, Number(process.env.OVERLORD_THUMBNAIL_HEIGHT || 1080));
const THUMBNAIL_QUALITY = Math.min(90, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 72))); const THUMBNAIL_QUALITY = Math.min(95, Math.max(40, Number(process.env.OVERLORD_THUMBNAIL_QUALITY || 88)));
const MAX_THUMBNAIL_SOURCE_BYTES = Math.max( const MAX_THUMBNAIL_SOURCE_BYTES = Math.max(
256 * 1024, 256 * 1024,
Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024), Number(process.env.OVERLORD_THUMBNAIL_MAX_SOURCE_BYTES || 16 * 1024 * 1024),