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();