add grouping and make it obvious
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 = '<option value="all">All Groups</option><option value="none">No Group</option>';
|
||||
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 = `
|
||||
<h3 class="text-lg font-semibold text-slate-100 flex items-center gap-2"><i class="fa-solid fa-layer-group text-blue-400"></i> Set Group</h3>
|
||||
<div class="group-picker-list flex flex-col gap-1 overflow-y-auto max-h-60"></div>
|
||||
<div class="border-t border-slate-700 pt-3">
|
||||
<p class="text-xs text-slate-400 mb-2">Create new group</p>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" class="group-new-name flex-1 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 outline-none focus:border-blue-500" placeholder="Group name" maxlength="64" />
|
||||
<input type="color" class="group-new-color w-10 h-10 rounded-lg border border-slate-700 bg-slate-800 cursor-pointer" value="#3b82f6" />
|
||||
<button class="group-create-btn px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<i class="fa-solid fa-xmark text-slate-500"></i> 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 = `<span class="inline-block w-3 h-3 rounded-full flex-shrink-0" style="background:${g.color}"></span> ${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)) {
|
||||
|
||||
@@ -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} <span class="hw-gpu-count">×${n}</span>` : 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({
|
||||
<span>${escapeHtml(displayName)}</span>
|
||||
${nickname && client.host ? `<span class="pill pill-ghost text-xs"><i class="fa-solid fa-laptop"></i> ${escapeHtml(client.host)}</span>` : ""}
|
||||
${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>` : ""}
|
||||
${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="text-slate-300 text-lg font-semibold flex items-center gap-1"><i class="fa-solid fa-user"></i> ${escapeHtml(client.user || "unknown")}</span>
|
||||
<span class="pill ${client.online ? "pill-online" : "pill-offline"}">
|
||||
<i class="fa-solid fa-circle"></i>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const state = {
|
||||
filterStatus: "all",
|
||||
filterOs: "all",
|
||||
filterCountry: "all",
|
||||
filterGroup: "all",
|
||||
lastDigest: "",
|
||||
isLoading: false,
|
||||
pendingForce: false,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -88,6 +88,13 @@
|
||||
>
|
||||
<option value="all">All OS</option>
|
||||
</select>
|
||||
<select
|
||||
id="filter-group"
|
||||
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-slate-100"
|
||||
>
|
||||
<option value="all">All Groups</option>
|
||||
<option value="none">No Group</option>
|
||||
</select>
|
||||
<div id="country-picker-anchor" class="relative"></div>
|
||||
<select
|
||||
id="sort"
|
||||
@@ -100,6 +107,8 @@
|
||||
<option value="ping_desc">Ping ↓</option>
|
||||
<option value="country_asc">Country A→Z</option>
|
||||
<option value="country_desc">Country Z→A</option>
|
||||
<option value="group_asc">Group A→Z</option>
|
||||
<option value="group_desc">Group Z→A</option>
|
||||
<option value="admin_first">Admin first</option>
|
||||
</select>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user