diff --git a/Overlord-Server/public/assets/data.js b/Overlord-Server/public/assets/data.js index 46c7f5f..72a11ad 100644 --- a/Overlord-Server/public/assets/data.js +++ b/Overlord-Server/public/assets/data.js @@ -57,6 +57,7 @@ export async function loadWithOptions(options = {}) { status: state.filterStatus || "all", os: state.filterOs || "all", country: state.filterCountry || "all", + group: state.filterGroup || "all", }); const res = await fetch(`/api/clients?${params.toString()}`); if (!res.ok) throw new Error(`status ${res.status}`); diff --git a/Overlord-Server/public/assets/main.css b/Overlord-Server/public/assets/main.css index 299b651..15f332f 100644 --- a/Overlord-Server/public/assets/main.css +++ b/Overlord-Server/public/assets/main.css @@ -1781,7 +1781,7 @@ mark.search-current { #grid article.card { position: relative; overflow: hidden; - border-color: rgba(71, 85, 105, 0.55) !important; + border-color: var(--group-color, rgba(71, 85, 105, 0.55)) !important; backdrop-filter: blur(4px); transition: transform 170ms ease, @@ -1791,10 +1791,11 @@ mark.search-current { #grid article.card:hover { transform: translateY(-2px); - border-color: rgba(99, 102, 241, 0.45) !important; + border-color: var(--group-color, rgba(99, 102, 241, 0.45)) !important; box-shadow: 0 18px 36px rgba(2, 6, 23, 0.45), - 0 0 0 1px rgba(99, 102, 241, 0.14); + 0 0 0 1px color-mix(in srgb, var(--group-color, rgba(99, 102, 241, 0.14)) 40%, transparent), + 0 0 20px 4px color-mix(in srgb, var(--group-color, transparent) 30%, transparent); } /* Cohesive UI polish layer */ diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 2c75bf2..351c508 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -22,6 +22,7 @@ const searchInput = document.getElementById("search"); const sortSelect = document.getElementById("sort"); const filterStatusSelect = document.getElementById("filter-status"); const filterOsSelect = document.getElementById("filter-os"); +const filterGroupSelect = document.getElementById("filter-group"); const showOfflineToggle = document.getElementById("toggle-offline"); const selectAllBtn = document.getElementById("select-all"); const clearSelectionBtn = document.getElementById("clear-selection"); @@ -45,6 +46,7 @@ const PREF_FILTER_STATUS_KEY = "overlord_filter_status"; const PREF_SORT_KEY = "overlord_sort"; const PREF_FILTER_OS_KEY = "overlord_filter_os"; const PREF_FILTER_COUNTRY_KEY = "overlord_filter_country"; +const PREF_FILTER_GROUP_KEY = "overlord_filter_group"; let currentUser = null; let contextCard = null; @@ -498,6 +500,7 @@ function initializeRenderer() { userRole: currentUser?.role, }); registerRenderer(renderMerge); + refreshGroupFilter(); loadWithOptions(); startAutoRefresh(); @@ -588,6 +591,14 @@ filterOsSelect?.addEventListener("change", (e) => { loadWithOptions({ force: true, reorder: true }); }); +filterGroupSelect?.addEventListener("change", (e) => { + state.filterGroup = e.target.value; + localStorage.setItem(PREF_FILTER_GROUP_KEY, state.filterGroup); + state.page = 1; + state.lastDigest = ""; + loadWithOptions({ force: true, reorder: true }); +}); + initCountryPicker((code) => { state.filterCountry = code; localStorage.setItem(PREF_FILTER_COUNTRY_KEY, code); @@ -607,7 +618,7 @@ initCountryPicker((code) => { } const savedSort = localStorage.getItem(PREF_SORT_KEY); - const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc"]; + const validSorts = ["stable", "last_seen_desc", "host_asc", "ping_asc", "ping_desc", "country_asc", "country_desc", "group_asc", "group_desc"]; if (savedSort && validSorts.includes(savedSort)) { state.sort = savedSort; if (sortSelect) sortSelect.value = savedSort; @@ -623,6 +634,12 @@ initCountryPicker((code) => { if (savedCountry) { state.filterCountry = savedCountry; } + + const savedGroup = localStorage.getItem(PREF_FILTER_GROUP_KEY); + if (savedGroup) { + state.filterGroup = savedGroup; + if (filterGroupSelect) filterGroupSelect.value = savedGroup; + } })(); showOfflineToggle?.addEventListener("change", (e) => { @@ -834,6 +851,148 @@ window.setClientTag = async (clientId, tag, note) => { } }; +window.setClientGroup = async (clientId, groupId) => { + if (!clientId) return false; + try { + const res = await fetch(`/api/clients/${clientId}/group`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ groupId }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error || "Failed to update client group"); + return false; + } + return true; + } catch (err) { + console.error(err); + alert("Failed to update client group"); + return false; + } +}; + +async function loadGroups() { + try { + const res = await fetch("/api/groups"); + if (!res.ok) return []; + const data = await res.json(); + return data.groups || []; + } catch { return []; } +} + +async function refreshGroupFilter() { + const groups = await loadGroups(); + if (!filterGroupSelect) return groups; + const current = filterGroupSelect.value; + filterGroupSelect.innerHTML = ''; + groups.forEach((g) => { + const opt = document.createElement("option"); + opt.value = String(g.id); + opt.textContent = g.name; + opt.style.color = g.color; + filterGroupSelect.appendChild(opt); + }); + filterGroupSelect.value = current; + return groups; +} + +async function openGroupPicker(clientId) { + const groups = await loadGroups(); + const card = getClientCard(clientId); + const currentGroupId = card?.dataset.groupId || ""; + + const overlay = document.createElement("div"); + overlay.className = "fixed inset-0 z-[10000] flex items-center justify-center bg-black/60"; + overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); + + const modal = document.createElement("div"); + modal.className = "bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-6 w-[400px] max-h-[80vh] flex flex-col gap-4"; + modal.innerHTML = ` +

Set Group

+
+
+

Create new group

+
+ + + +
+
+ `; + + const listEl = modal.querySelector(".group-picker-list"); + + function renderList() { + listEl.innerHTML = ""; + const noneBtn = document.createElement("button"); + noneBtn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors ${!currentGroupId ? "bg-slate-700 text-white" : "text-slate-300 hover:bg-slate-800"}`; + noneBtn.innerHTML = ' No Group'; + noneBtn.addEventListener("click", async () => { + const ok = await window.setClientGroup(clientId, null); + if (ok) setTimeout(() => loadWithOptions({ force: true }), 200); + overlay.remove(); + }); + listEl.appendChild(noneBtn); + + groups.forEach((g) => { + const btn = document.createElement("button"); + const isActive = String(g.id) === currentGroupId; + btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-center gap-2 transition-colors ${isActive ? "bg-slate-700 text-white" : "text-slate-300 hover:bg-slate-800"}`; + btn.innerHTML = ` ${escapeHtml(g.name)}`; + btn.addEventListener("click", async () => { + const ok = await window.setClientGroup(clientId, g.id); + if (ok) setTimeout(() => loadWithOptions({ force: true }), 200); + overlay.remove(); + }); + listEl.appendChild(btn); + }); + } + + renderList(); + + const createBtn = modal.querySelector(".group-create-btn"); + createBtn.addEventListener("click", async () => { + const nameInput = modal.querySelector(".group-new-name"); + const colorInput = modal.querySelector(".group-new-color"); + const name = nameInput.value.trim(); + const color = colorInput.value; + if (!name) { nameInput.focus(); return; } + try { + const res = await fetch("/api/groups", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, color }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + alert(d.error || "Failed to create group"); + return; + } + const newGroup = await res.json(); + groups.push(newGroup); + nameInput.value = ""; + renderList(); + refreshGroupFilter(); + } catch (err) { + console.error(err); + alert("Failed to create group"); + } + }); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + modal.querySelector(".group-new-name").addEventListener("keydown", (e) => { + if (e.key === "Enter") createBtn.click(); + }); +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + window.banClient = async (clientId) => { if (!clientId) return; if (!confirm(`Ban IP for ${clientId} and block future connections?`)) return; @@ -1068,6 +1227,11 @@ menu.addEventListener("click", async (e) => { } closeMenu(clearContext); return; + } else if (action === "set-group") { + const savedClientId = contextCard; + closeMenu(clearContext); + openGroupPicker(savedClientId); + return; } if (!isClientOnline(contextCard)) { diff --git a/Overlord-Server/public/assets/render.js b/Overlord-Server/public/assets/render.js index 5d858a8..94ef69c 100644 --- a/Overlord-Server/public/assets/render.js +++ b/Overlord-Server/public/assets/render.js @@ -16,7 +16,7 @@ function escapeHtml(text) { } 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.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${JSON.stringify(c.permissions)}`; + 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}`; } export function createRenderer({ @@ -373,6 +373,9 @@ export function createRenderer({ 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 arch = archBadge(client.arch || ""); @@ -387,6 +390,8 @@ export function createRenderer({ 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 isHwExpanded = hasHwInfo && wasHwExpanded; card.dataset.hwExpanded = isHwExpanded ? "true" : "false"; @@ -397,7 +402,13 @@ export function createRenderer({ return [...counts.entries()].map(([name, n]) => n > 1 ? `${name} ×${n}` : escapeHtml(name)).join(", "); }; const gpuHtml = dedupeGpu(client.gpu); - card.className = `card rounded-xl border ${client.bookmarked ? "border-yellow-600/60" : "border-slate-800"} bg-slate-900/70 p-4 shadow-lg ${client.online ? "" : "card-offline"} tone-${os.tone}`; + const borderClass = client.bookmarked ? "border-yellow-600/60" : "border-slate-800"; + card.className = `card rounded-xl border ${borderClass} bg-slate-900/70 p-4 shadow-lg ${client.online ? "" : "card-offline"} tone-${os.tone}`; + 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"); @@ -429,6 +440,7 @@ export function createRenderer({ ${escapeHtml(displayName)} ${nickname && client.host ? ` ${escapeHtml(client.host)}` : ""} ${customTag ? `` : ""} + ${groupName ? ` ${escapeHtml(groupName)}` : ""} ${escapeHtml(client.user || "unknown")} diff --git a/Overlord-Server/public/assets/state.js b/Overlord-Server/public/assets/state.js index 9b31748..f53f0ac 100644 --- a/Overlord-Server/public/assets/state.js +++ b/Overlord-Server/public/assets/state.js @@ -6,6 +6,7 @@ export const state = { filterStatus: "all", filterOs: "all", filterCountry: "all", + filterGroup: "all", lastDigest: "", isLoading: false, pendingForce: false, diff --git a/Overlord-Server/public/assets/ui.js b/Overlord-Server/public/assets/ui.js index 1530b92..bb95310 100644 --- a/Overlord-Server/public/assets/ui.js +++ b/Overlord-Server/public/assets/ui.js @@ -44,6 +44,7 @@ const MENU_GROUPS = [ { label: "Reconnect", icon: "fa-solid fa-rotate", icolor: "text-slate-300", action: "reconnect" }, { label: "Set Nickname", icon: "fa-solid fa-signature", icolor: "text-slate-300", action: "set-nickname" }, { label: "Set Custom Tag", icon: "fa-solid fa-tag", icolor: "text-slate-300", action: "set-custom-tag" }, + { label: "Set Group", icon: "fa-solid fa-layer-group", icolor: "text-blue-300", action: "set-group" }, { divider: true }, { label: "Elevate (macOS)", icon: "fa-solid fa-arrow-up-right-dots", icolor: "text-green-400", action: "elevate" }, { divider: true }, diff --git a/Overlord-Server/public/index.html b/Overlord-Server/public/index.html index 96622be..b731efd 100644 --- a/Overlord-Server/public/index.html +++ b/Overlord-Server/public/index.html @@ -88,6 +88,13 @@ > +