import { ThumbnailLoader, thumbnailUrl } from "./thumbnail-loader.js"; const grid = document.getElementById("wall-grid"); const emptyEl = document.getElementById("wall-empty"); const searchInput = document.getElementById("wall-search"); const intervalSelect = document.getElementById("wall-interval"); const sizeSelect = document.getElementById("wall-size"); const showOfflineInput = document.getElementById("wall-show-offline"); const refreshNowBtn = document.getElementById("wall-refresh-now"); const statsEl = document.getElementById("wall-stats"); const PREF_INTERVAL = "wall_interval_ms"; const PREF_SIZE = "wall_tile_w"; const PREF_SHOW_OFFLINE = "wall_show_offline"; const PAGE_SIZE = 60; const LIST_POLL_MS = 15_000; const SEARCH_DEBOUNCE_MS = 200; let clients = []; let totalOnline = 0; let totalAll = 0; let currentPage = 1; let listPollTimer = null; let searchDebounceTimer = null; const tileEls = new Map(); const loader = new ThumbnailLoader({ refreshIntervalMs: 3000, rootMargin: "300px", threshold: 0.05, }); function readPref(key, fallback) { try { const v = localStorage.getItem(key); if (v == null || v === "") return fallback; return v; } catch { return fallback; } } function writePref(key, value) { try { localStorage.setItem(key, String(value)); } catch {} } function loadPrefs() { const interval = readPref(PREF_INTERVAL, "3000"); if (intervalSelect && [...intervalSelect.options].some((o) => o.value === interval)) { intervalSelect.value = interval; } const size = readPref(PREF_SIZE, "320"); if (sizeSelect && [...sizeSelect.options].some((o) => o.value === size)) { sizeSelect.value = size; } const showOffline = readPref(PREF_SHOW_OFFLINE, "false"); if (showOfflineInput) showOfflineInput.checked = showOffline === "true"; applyTileSize(); applyIntervalToLoader(); } function applyTileSize() { const w = Number(sizeSelect?.value) || 320; document.documentElement.style.setProperty("--wall-tile-w", `${w}px`); } function applyIntervalToLoader() { const ms = Number(intervalSelect?.value); if (!Number.isFinite(ms) || ms <= 0) { loader.setRefreshInterval(60 * 60 * 1000); return; } loader.setRefreshInterval(ms); } function buildTile(client) { const tile = document.createElement("a"); tile.className = "wall-tile cv-thumb-host"; tile.dataset.id = client.id; tile.dataset.thumbHost = ""; tile.dataset.thumbClient = client.id; tile.dataset.thumbVersion = String(client.thumbnailVersion || 0); tile.dataset.thumbOnline = client.online ? "1" : "0"; if (client.online) { tile.href = `/remotedesktop?clientId=${encodeURIComponent(client.id)}`; tile.target = "_blank"; tile.rel = "noopener"; } else { tile.classList.add("is-offline"); tile.addEventListener("click", (e) => e.preventDefault()); } const img = document.createElement("img"); img.className = "wall-img"; img.dataset.thumbImg = ""; img.alt = ""; img.decoding = "async"; img.loading = "lazy"; if (client.hasThumbnail) { const url = thumbnailUrl(client.id, client.thumbnailVersion || 0); img.src = url; img.dataset.thumbUrl = url; } else { img.style.display = "none"; } img.addEventListener("load", () => { img.style.display = "block"; const empty = tile.querySelector(".wall-empty"); if (empty) empty.style.display = "none"; }); img.addEventListener("error", () => { img.style.display = "none"; img.removeAttribute("src"); const empty = tile.querySelector(".wall-empty"); if (empty) empty.style.display = ""; }); tile.appendChild(img); const empty = document.createElement("div"); empty.className = "wall-empty"; empty.innerHTML = `${client.online ? "Waiting for screenshot…" : "Offline"}`; if (client.hasThumbnail) empty.style.display = "none"; tile.appendChild(empty); const loading = document.createElement("div"); loading.className = "wall-loading"; tile.appendChild(loading); const top = document.createElement("div"); top.className = "wall-overlay-top"; top.innerHTML = ` `; tile.appendChild(top); const bottom = document.createElement("div"); bottom.className = "wall-overlay-bottom"; bottom.innerHTML = ` `; tile.appendChild(bottom); return tile; } function updateTile(tile, client) { const isOnline = !!client.online; tile.classList.toggle("is-offline", !isOnline); if (isOnline) { tile.href = `/remotedesktop?clientId=${encodeURIComponent(client.id)}`; tile.target = "_blank"; tile.rel = "noopener"; } else { tile.removeAttribute("href"); tile.removeAttribute("target"); } tile.dataset.thumbOnline = isOnline ? "1" : "0"; tile.dataset.thumbVersion = String(client.thumbnailVersion || 0); const dot = tile.querySelector(".wall-dot"); if (dot) { dot.classList.toggle("on", isOnline); dot.classList.toggle("off", !isOnline); } const hostEl = tile.querySelector(".wall-host"); if (hostEl) { const label = client.nickname || client.host || client.id; hostEl.textContent = label; hostEl.title = `${label}\n${client.id}`; } const userEl = tile.querySelector(".wall-user"); if (userEl) userEl.textContent = client.user || "—"; const osEl = tile.querySelector(".wall-os"); if (osEl) osEl.textContent = client.os ? `· ${client.os}` : ""; if (!client.hasThumbnail) { const img = tile.querySelector("img[data-thumb-img]"); if (img && !img.dataset.thumbUrl) { img.removeAttribute("src"); img.style.display = "none"; } const empty = tile.querySelector(".wall-empty"); if (empty) empty.style.display = ""; } } function pageClients() { const showOffline = !!showOfflineInput?.checked; return clients.filter((c) => showOffline || c.online); } function renderGrid() { const visible = pageClients(); const seen = new Set(); for (const client of visible) { seen.add(client.id); let tile = tileEls.get(client.id); if (!tile) { tile = buildTile(client); tileEls.set(client.id, tile); grid.appendChild(tile); } else if (tile.parentNode !== grid) { grid.appendChild(tile); } updateTile(tile, client); if (client.online) { loader.observe(tile, client.id, Number(client.thumbnailVersion) || 0); } else { loader.unobserve(tile); } } for (const [id, tile] of tileEls) { if (!seen.has(id)) { loader.unobserve(tile); tile.remove(); tileEls.delete(id); } } if (statsEl) { const shownLabel = visible.length === clients.length ? `${visible.length} shown` : `${visible.length} on page · ${totalOnline} online · ${totalAll} total`; statsEl.textContent = shownLabel; } if (emptyEl) emptyEl.classList.toggle("hidden", visible.length > 0); renderPagination(); } let paginationEl = null; function renderPagination() { const totalForView = showOfflineInput?.checked ? totalAll : totalOnline; const totalPages = Math.max(1, Math.ceil(totalForView / PAGE_SIZE)); if (totalForView <= PAGE_SIZE) { if (paginationEl) { paginationEl.remove(); paginationEl = null; } return; } if (!paginationEl) { paginationEl = document.createElement("div"); paginationEl.className = "flex items-center justify-center gap-3 py-4 text-slate-300"; grid.parentNode.insertBefore(paginationEl, grid.nextSibling); } paginationEl.innerHTML = ` Page ${currentPage} of ${totalPages} `; paginationEl.querySelector('[data-act="prev"]')?.addEventListener("click", () => { if (currentPage > 1) { currentPage -= 1; fetchClients(); } }); paginationEl.querySelector('[data-act="next"]')?.addEventListener("click", () => { if (currentPage < totalPages) { currentPage += 1; fetchClients(); } }); } function getSearchTerm() { return (searchInput?.value || "").trim(); } async function fetchClients() { const params = new URLSearchParams({ page: String(currentPage), pageSize: String(PAGE_SIZE), sort: "host_asc", q: getSearchTerm(), status: showOfflineInput?.checked ? "all" : "online", os: "all", country: "all", group: "all", }); try { const res = await fetch(`/api/clients?${params.toString()}`, { credentials: "include" }); if (!res.ok) throw new Error(`status ${res.status}`); const data = await res.json(); clients = Array.isArray(data.items) ? data.items : []; totalOnline = Number(data.online) || 0; totalAll = Number(data.total) || 0; renderGrid(); for (const c of clients) { if (typeof c.thumbnailVersion === "number") { loader.setVersion(c.id, c.thumbnailVersion); } } } catch (err) { console.error("[wall] fetchClients failed", err); } } function debouncedFetch() { if (searchDebounceTimer) clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { currentPage = 1; fetchClients(); }, SEARCH_DEBOUNCE_MS); } function startListPoll() { if (listPollTimer) return; listPollTimer = setInterval(() => { if (document.hidden) return; fetchClients(); }, LIST_POLL_MS); } function setupEvents() { searchInput?.addEventListener("input", debouncedFetch); intervalSelect?.addEventListener("change", () => { writePref(PREF_INTERVAL, intervalSelect.value); applyIntervalToLoader(); }); sizeSelect?.addEventListener("change", () => { writePref(PREF_SIZE, sizeSelect.value); applyTileSize(); }); showOfflineInput?.addEventListener("change", () => { writePref(PREF_SHOW_OFFLINE, String(showOfflineInput.checked)); currentPage = 1; fetchClients(); }); refreshNowBtn?.addEventListener("click", () => { loader.refreshNow(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden) fetchClients(); }); } async function init() { loadPrefs(); setupEvents(); await fetchClients(); startListPoll(); } init();