|
|
|
@@ -15,6 +15,10 @@ function escapeHtml(text) {
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cardDigest(c) {
|
|
|
|
|
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.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createRenderer({
|
|
|
|
|
grid,
|
|
|
|
|
totalPill,
|
|
|
|
@@ -32,15 +36,209 @@ export function createRenderer({
|
|
|
|
|
const TOUCH_LONG_PRESS_MS = 520;
|
|
|
|
|
const TOUCH_MOVE_CANCEL_PX = 10;
|
|
|
|
|
let renderToken = 0;
|
|
|
|
|
let gridDelegated = false;
|
|
|
|
|
|
|
|
|
|
/* ── Event delegation ─────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
function setupGridDelegation() {
|
|
|
|
|
if (gridDelegated) return;
|
|
|
|
|
gridDelegated = true;
|
|
|
|
|
|
|
|
|
|
grid.addEventListener("click", (e) => {
|
|
|
|
|
const card = e.target.closest("article[data-id]");
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(".client-tag-toggle")) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleTagToggle(card);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (e.target.closest(".hw-toggle")) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleHwToggle(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("article[data-id]");
|
|
|
|
|
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("article[data-id]");
|
|
|
|
|
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("article[data-id]");
|
|
|
|
|
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("article[data-id]");
|
|
|
|
|
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 (!isBookmarked) {
|
|
|
|
|
btn.classList.remove("border-slate-700", "bg-slate-800/50", "text-slate-500");
|
|
|
|
|
btn.classList.add("border-yellow-600", "bg-yellow-900/50", "text-yellow-300");
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} 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 reorderCards(items) {
|
|
|
|
|
const cards = grid.querySelectorAll("article[data-id]");
|
|
|
|
|
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 = grid.querySelector(`article[data-id="${client.id}"]`);
|
|
|
|
|
if (card) grid.appendChild(card);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function renderMerge(data, options = {}) {
|
|
|
|
|
setupGridDelegation();
|
|
|
|
|
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));
|
|
|
|
@@ -62,6 +260,9 @@ export function createRenderer({
|
|
|
|
|
seen.add(client.id);
|
|
|
|
|
const existing = grid.querySelector(`article[data-id="${client.id}"]`);
|
|
|
|
|
if (existing) {
|
|
|
|
|
const digest = cardDigest(client);
|
|
|
|
|
if (existing._cardDigest === digest) return;
|
|
|
|
|
existing._cardDigest = digest;
|
|
|
|
|
updateCard(existing, client);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
@@ -122,56 +323,9 @@ export function createRenderer({
|
|
|
|
|
const card = document.createElement("article");
|
|
|
|
|
card.dataset.id = client.id;
|
|
|
|
|
card.dataset.hwid = client.hwid || "";
|
|
|
|
|
let longPressTimer = null;
|
|
|
|
|
card._longPressTriggered = false;
|
|
|
|
|
let pointerStartX = 0;
|
|
|
|
|
let pointerStartY = 0;
|
|
|
|
|
|
|
|
|
|
const clearLongPress = () => {
|
|
|
|
|
if (longPressTimer) {
|
|
|
|
|
clearTimeout(longPressTimer);
|
|
|
|
|
longPressTimer = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
updateCard(card, client);
|
|
|
|
|
card.addEventListener("contextmenu", (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (isViewer) return;
|
|
|
|
|
const clientId = card.dataset.id;
|
|
|
|
|
if (!clientId) return;
|
|
|
|
|
const { clientX, clientY } = e;
|
|
|
|
|
openMenu(clientId, clientX, clientY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
card.addEventListener("pointerdown", (e) => {
|
|
|
|
|
if (isViewer || e.pointerType !== "touch") return;
|
|
|
|
|
if (e.target.closest("button") || e.target.closest(".client-checkbox")) return;
|
|
|
|
|
const clientId = card.dataset.id;
|
|
|
|
|
if (!clientId) return;
|
|
|
|
|
|
|
|
|
|
card._longPressTriggered = false;
|
|
|
|
|
pointerStartX = e.clientX;
|
|
|
|
|
pointerStartY = e.clientY;
|
|
|
|
|
clearLongPress();
|
|
|
|
|
longPressTimer = setTimeout(() => {
|
|
|
|
|
card._longPressTriggered = true;
|
|
|
|
|
openMenu(clientId, e.clientX, e.clientY);
|
|
|
|
|
}, TOUCH_LONG_PRESS_MS);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
card.addEventListener("pointermove", (e) => {
|
|
|
|
|
if (!longPressTimer || e.pointerType !== "touch") return;
|
|
|
|
|
const movedX = Math.abs(e.clientX - pointerStartX);
|
|
|
|
|
const movedY = Math.abs(e.clientY - pointerStartY);
|
|
|
|
|
if (movedX > TOUCH_MOVE_CANCEL_PX || movedY > TOUCH_MOVE_CANCEL_PX) {
|
|
|
|
|
clearLongPress();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
card.addEventListener("pointerup", clearLongPress);
|
|
|
|
|
card.addEventListener("pointercancel", clearLongPress);
|
|
|
|
|
card.addEventListener("pointerleave", clearLongPress);
|
|
|
|
|
card._cardDigest = cardDigest(client);
|
|
|
|
|
|
|
|
|
|
if (options.animate) {
|
|
|
|
|
card.classList.add("card-animate");
|
|
|
|
@@ -322,121 +476,7 @@ export function createRenderer({
|
|
|
|
|
if ((wasChecked || isSelected) && client.online) {
|
|
|
|
|
checkbox.checked = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
checkbox.addEventListener("change", (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (window.toggleClientSelection) {
|
|
|
|
|
window.toggleClientSelection(client.id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isViewer) {
|
|
|
|
|
card.querySelector(".command-btn")?.addEventListener("click", (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
|
openMenu(client.id, rect.right, rect.bottom);
|
|
|
|
|
});
|
|
|
|
|
card.querySelector(".ban-btn")?.addEventListener("click", (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
if (window.banClient) {
|
|
|
|
|
window.banClient(client.id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
card.querySelector(".bookmark-btn")?.addEventListener("click", async (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
const btn = e.currentTarget;
|
|
|
|
|
const id = btn.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 (!isBookmarked) {
|
|
|
|
|
btn.classList.remove("border-slate-700", "bg-slate-800/50", "text-slate-500");
|
|
|
|
|
btn.classList.add("border-yellow-600", "bg-yellow-900/50", "text-yellow-300");
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("bookmark toggle failed", err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
card.querySelector(".client-tag-toggle")?.addEventListener("click", (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
card.querySelector(".hw-toggle")?.addEventListener("click", (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
card
|
|
|
|
|
.querySelector(".thumb-img")
|
|
|
|
|
?.addEventListener("click", () => openModal(client.thumbnail));
|
|
|
|
|
|
|
|
|
|
card.onclick = (e) => {
|
|
|
|
|
if (card._longPressTriggered) {
|
|
|
|
|
card._longPressTriggered = false;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.target.closest(".command-btn") || e.target.closest("button"))
|
|
|
|
|
return;
|
|
|
|
|
if (e.target.closest(".client-checkbox")) return;
|
|
|
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
|
|
|
if (checkbox && !checkbox.disabled) {
|
|
|
|
|
checkbox.checked = !checkbox.checked;
|
|
|
|
|
if (window.toggleClientSelection) {
|
|
|
|
|
window.toggleClientSelection(client.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!client.online) return;
|
|
|
|
|
if (pingClient) pingClient(client.id);
|
|
|
|
|
requestThumbnail(client.id);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { renderMerge };
|
|
|
|
|