add bookmarking clients, fix sorting issues, add sorting by country and only showing specific countries.
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
// Country data: code -> [name, continent]
|
||||
const COUNTRIES = {
|
||||
AF: ["Afghanistan", "Asia"],
|
||||
AL: ["Albania", "Europe"],
|
||||
DZ: ["Algeria", "Africa"],
|
||||
AD: ["Andorra", "Europe"],
|
||||
AO: ["Angola", "Africa"],
|
||||
AG: ["Antigua & Barbuda", "North America"],
|
||||
AR: ["Argentina", "South America"],
|
||||
AM: ["Armenia", "Asia"],
|
||||
AU: ["Australia", "Oceania"],
|
||||
AT: ["Austria", "Europe"],
|
||||
AZ: ["Azerbaijan", "Asia"],
|
||||
BS: ["Bahamas", "North America"],
|
||||
BH: ["Bahrain", "Asia"],
|
||||
BD: ["Bangladesh", "Asia"],
|
||||
BB: ["Barbados", "North America"],
|
||||
BY: ["Belarus", "Europe"],
|
||||
BE: ["Belgium", "Europe"],
|
||||
BZ: ["Belize", "North America"],
|
||||
BJ: ["Benin", "Africa"],
|
||||
BT: ["Bhutan", "Asia"],
|
||||
BO: ["Bolivia", "South America"],
|
||||
BA: ["Bosnia & Herzegovina", "Europe"],
|
||||
BW: ["Botswana", "Africa"],
|
||||
BR: ["Brazil", "South America"],
|
||||
BN: ["Brunei", "Asia"],
|
||||
BG: ["Bulgaria", "Europe"],
|
||||
BF: ["Burkina Faso", "Africa"],
|
||||
BI: ["Burundi", "Africa"],
|
||||
CV: ["Cabo Verde", "Africa"],
|
||||
KH: ["Cambodia", "Asia"],
|
||||
CM: ["Cameroon", "Africa"],
|
||||
CA: ["Canada", "North America"],
|
||||
CF: ["Central African Republic", "Africa"],
|
||||
TD: ["Chad", "Africa"],
|
||||
CL: ["Chile", "South America"],
|
||||
CN: ["China", "Asia"],
|
||||
CO: ["Colombia", "South America"],
|
||||
KM: ["Comoros", "Africa"],
|
||||
CG: ["Congo", "Africa"],
|
||||
CD: ["Congo (DR)", "Africa"],
|
||||
CR: ["Costa Rica", "North America"],
|
||||
HR: ["Croatia", "Europe"],
|
||||
CU: ["Cuba", "North America"],
|
||||
CY: ["Cyprus", "Europe"],
|
||||
CZ: ["Czechia", "Europe"],
|
||||
DK: ["Denmark", "Europe"],
|
||||
DJ: ["Djibouti", "Africa"],
|
||||
DM: ["Dominica", "North America"],
|
||||
DO: ["Dominican Republic", "North America"],
|
||||
EC: ["Ecuador", "South America"],
|
||||
EG: ["Egypt", "Africa"],
|
||||
SV: ["El Salvador", "North America"],
|
||||
GQ: ["Equatorial Guinea", "Africa"],
|
||||
ER: ["Eritrea", "Africa"],
|
||||
EE: ["Estonia", "Europe"],
|
||||
SZ: ["Eswatini", "Africa"],
|
||||
ET: ["Ethiopia", "Africa"],
|
||||
FJ: ["Fiji", "Oceania"],
|
||||
FI: ["Finland", "Europe"],
|
||||
FR: ["France", "Europe"],
|
||||
GA: ["Gabon", "Africa"],
|
||||
GM: ["Gambia", "Africa"],
|
||||
GE: ["Georgia", "Asia"],
|
||||
DE: ["Germany", "Europe"],
|
||||
GH: ["Ghana", "Africa"],
|
||||
GR: ["Greece", "Europe"],
|
||||
GD: ["Grenada", "North America"],
|
||||
GT: ["Guatemala", "North America"],
|
||||
GN: ["Guinea", "Africa"],
|
||||
GW: ["Guinea-Bissau", "Africa"],
|
||||
GY: ["Guyana", "South America"],
|
||||
HT: ["Haiti", "North America"],
|
||||
HN: ["Honduras", "North America"],
|
||||
HU: ["Hungary", "Europe"],
|
||||
IS: ["Iceland", "Europe"],
|
||||
IN: ["India", "Asia"],
|
||||
ID: ["Indonesia", "Asia"],
|
||||
IR: ["Iran", "Asia"],
|
||||
IQ: ["Iraq", "Asia"],
|
||||
IE: ["Ireland", "Europe"],
|
||||
IL: ["Israel", "Asia"],
|
||||
IT: ["Italy", "Europe"],
|
||||
JM: ["Jamaica", "North America"],
|
||||
JP: ["Japan", "Asia"],
|
||||
JO: ["Jordan", "Asia"],
|
||||
KZ: ["Kazakhstan", "Asia"],
|
||||
KE: ["Kenya", "Africa"],
|
||||
KI: ["Kiribati", "Oceania"],
|
||||
KP: ["North Korea", "Asia"],
|
||||
KR: ["South Korea", "Asia"],
|
||||
KW: ["Kuwait", "Asia"],
|
||||
KG: ["Kyrgyzstan", "Asia"],
|
||||
LA: ["Laos", "Asia"],
|
||||
LV: ["Latvia", "Europe"],
|
||||
LB: ["Lebanon", "Asia"],
|
||||
LS: ["Lesotho", "Africa"],
|
||||
LR: ["Liberia", "Africa"],
|
||||
LY: ["Libya", "Africa"],
|
||||
LI: ["Liechtenstein", "Europe"],
|
||||
LT: ["Lithuania", "Europe"],
|
||||
LU: ["Luxembourg", "Europe"],
|
||||
MO: ["Macao", "Asia"],
|
||||
MG: ["Madagascar", "Africa"],
|
||||
MW: ["Malawi", "Africa"],
|
||||
MY: ["Malaysia", "Asia"],
|
||||
MV: ["Maldives", "Asia"],
|
||||
ML: ["Mali", "Africa"],
|
||||
MT: ["Malta", "Europe"],
|
||||
MH: ["Marshall Islands", "Oceania"],
|
||||
MR: ["Mauritania", "Africa"],
|
||||
MU: ["Mauritius", "Africa"],
|
||||
MX: ["Mexico", "North America"],
|
||||
FM: ["Micronesia", "Oceania"],
|
||||
MD: ["Moldova", "Europe"],
|
||||
MC: ["Monaco", "Europe"],
|
||||
MN: ["Mongolia", "Asia"],
|
||||
ME: ["Montenegro", "Europe"],
|
||||
MA: ["Morocco", "Africa"],
|
||||
MZ: ["Mozambique", "Africa"],
|
||||
MM: ["Myanmar", "Asia"],
|
||||
NA: ["Namibia", "Africa"],
|
||||
NR: ["Nauru", "Oceania"],
|
||||
NP: ["Nepal", "Asia"],
|
||||
NL: ["Netherlands", "Europe"],
|
||||
NZ: ["New Zealand", "Oceania"],
|
||||
NI: ["Nicaragua", "North America"],
|
||||
NE: ["Niger", "Africa"],
|
||||
NG: ["Nigeria", "Africa"],
|
||||
MK: ["North Macedonia", "Europe"],
|
||||
NO: ["Norway", "Europe"],
|
||||
OM: ["Oman", "Asia"],
|
||||
PK: ["Pakistan", "Asia"],
|
||||
PW: ["Palau", "Oceania"],
|
||||
PS: ["Palestine", "Asia"],
|
||||
PA: ["Panama", "North America"],
|
||||
PG: ["Papua New Guinea", "Oceania"],
|
||||
PY: ["Paraguay", "South America"],
|
||||
PE: ["Peru", "South America"],
|
||||
PH: ["Philippines", "Asia"],
|
||||
PL: ["Poland", "Europe"],
|
||||
PT: ["Portugal", "Europe"],
|
||||
QA: ["Qatar", "Asia"],
|
||||
RO: ["Romania", "Europe"],
|
||||
RU: ["Russia", "Europe"],
|
||||
RW: ["Rwanda", "Africa"],
|
||||
KN: ["Saint Kitts & Nevis", "North America"],
|
||||
LC: ["Saint Lucia", "North America"],
|
||||
VC: ["Saint Vincent & Grenadines", "North America"],
|
||||
WS: ["Samoa", "Oceania"],
|
||||
SM: ["San Marino", "Europe"],
|
||||
ST: ["São Tomé & Príncipe", "Africa"],
|
||||
SA: ["Saudi Arabia", "Asia"],
|
||||
SN: ["Senegal", "Africa"],
|
||||
RS: ["Serbia", "Europe"],
|
||||
SC: ["Seychelles", "Africa"],
|
||||
SL: ["Sierra Leone", "Africa"],
|
||||
SG: ["Singapore", "Asia"],
|
||||
SK: ["Slovakia", "Europe"],
|
||||
SI: ["Slovenia", "Europe"],
|
||||
SB: ["Solomon Islands", "Oceania"],
|
||||
SO: ["Somalia", "Africa"],
|
||||
ZA: ["South Africa", "Africa"],
|
||||
SS: ["South Sudan", "Africa"],
|
||||
ES: ["Spain", "Europe"],
|
||||
LK: ["Sri Lanka", "Asia"],
|
||||
SD: ["Sudan", "Africa"],
|
||||
SR: ["Suriname", "South America"],
|
||||
SE: ["Sweden", "Europe"],
|
||||
CH: ["Switzerland", "Europe"],
|
||||
SY: ["Syria", "Asia"],
|
||||
TW: ["Taiwan", "Asia"],
|
||||
TJ: ["Tajikistan", "Asia"],
|
||||
TZ: ["Tanzania", "Africa"],
|
||||
TH: ["Thailand", "Asia"],
|
||||
TL: ["Timor-Leste", "Asia"],
|
||||
TG: ["Togo", "Africa"],
|
||||
TO: ["Tonga", "Oceania"],
|
||||
TT: ["Trinidad & Tobago", "North America"],
|
||||
TN: ["Tunisia", "Africa"],
|
||||
TR: ["Turkey", "Asia"],
|
||||
TM: ["Turkmenistan", "Asia"],
|
||||
TV: ["Tuvalu", "Oceania"],
|
||||
UG: ["Uganda", "Africa"],
|
||||
UA: ["Ukraine", "Europe"],
|
||||
AE: ["United Arab Emirates", "Asia"],
|
||||
GB: ["United Kingdom", "Europe"],
|
||||
US: ["United States", "North America"],
|
||||
UY: ["Uruguay", "South America"],
|
||||
UZ: ["Uzbekistan", "Asia"],
|
||||
VU: ["Vanuatu", "Oceania"],
|
||||
VE: ["Venezuela", "South America"],
|
||||
VN: ["Vietnam", "Asia"],
|
||||
YE: ["Yemen", "Asia"],
|
||||
ZM: ["Zambia", "Africa"],
|
||||
ZW: ["Zimbabwe", "Africa"],
|
||||
};
|
||||
|
||||
const CONTINENT_ORDER = [
|
||||
"North America",
|
||||
"South America",
|
||||
"Europe",
|
||||
"Africa",
|
||||
"Asia",
|
||||
"Oceania",
|
||||
];
|
||||
|
||||
function flagHtml(code) {
|
||||
if (!code) return "";
|
||||
const cc = String(code).toLowerCase();
|
||||
if (!/^[a-z]{2}$/.test(cc) || cc === "zz") return "🏴";
|
||||
return `<span class="fi fi-${cc}"></span>`;
|
||||
}
|
||||
|
||||
export function initCountryPicker(onSelect) {
|
||||
const anchor = document.getElementById("country-picker-anchor");
|
||||
if (!anchor) return;
|
||||
|
||||
let currentCode = "all";
|
||||
let countryData = []; // [{ code, count }]
|
||||
let panelOpen = false;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.id = "country-picker-btn";
|
||||
btn.className =
|
||||
"inline-flex items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-slate-100 hover:border-slate-600 transition-colors";
|
||||
btn.innerHTML = `<i class="fa-solid fa-earth-americas text-slate-400"></i><span id="country-picker-label">All Countries</span><i class="fa-solid fa-chevron-down text-slate-400 text-xs"></i>`;
|
||||
anchor.appendChild(btn);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.id = "country-picker-panel";
|
||||
panel.className =
|
||||
"hidden absolute z-[9999] mt-1 w-72 rounded-xl border border-slate-700 bg-slate-900 shadow-2xl flex flex-col overflow-hidden";
|
||||
panel.style.maxHeight = "420px";
|
||||
|
||||
const searchWrap = document.createElement("div");
|
||||
searchWrap.className = "p-2 border-b border-slate-800 flex-shrink-0";
|
||||
searchWrap.innerHTML = `<div class="flex items-center gap-2 bg-slate-800 border border-slate-700 rounded-lg px-2 py-1.5"><i class="fa-solid fa-magnifying-glass text-slate-400 text-xs"></i><input id="country-search" type="search" placeholder="Search country..." class="bg-transparent outline-none text-sm text-slate-100 placeholder:text-slate-500 w-full"></div>`;
|
||||
panel.appendChild(searchWrap);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "overflow-y-auto flex-1";
|
||||
panel.appendChild(list);
|
||||
|
||||
document.body.appendChild(panel);
|
||||
|
||||
function positionPanel() {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
panel.style.top = `${rect.bottom + window.scrollY + 4}px`;
|
||||
panel.style.left = `${rect.left + window.scrollX}px`;
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panelOpen = true;
|
||||
panel.classList.remove("hidden");
|
||||
positionPanel();
|
||||
setTimeout(() => {
|
||||
const inp = panel.querySelector("#country-search");
|
||||
if (inp) inp.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen = false;
|
||||
panel.classList.add("hidden");
|
||||
const inp = panel.querySelector("#country-search");
|
||||
if (inp) inp.value = "";
|
||||
renderList("");
|
||||
}
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (panelOpen) {
|
||||
closePanel();
|
||||
} else {
|
||||
openPanel();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!panelOpen) return;
|
||||
if (!panel.contains(e.target) && e.target !== btn) {
|
||||
closePanel();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (panelOpen) positionPanel();
|
||||
});
|
||||
|
||||
const searchInput = searchWrap.querySelector("#country-search");
|
||||
searchInput.addEventListener("input", (e) => {
|
||||
renderList(e.target.value.trim().toLowerCase());
|
||||
});
|
||||
|
||||
function setSelected(code, label, flagMarkup) {
|
||||
currentCode = code;
|
||||
const labelEl = document.getElementById("country-picker-label");
|
||||
if (labelEl) {
|
||||
if (code === "all") {
|
||||
labelEl.innerHTML = "All Countries";
|
||||
} else {
|
||||
labelEl.innerHTML = `${flagMarkup} ${label}`;
|
||||
}
|
||||
}
|
||||
onSelect(code);
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function renderList(query) {
|
||||
list.innerHTML = "";
|
||||
|
||||
const presentCodes = new Set(countryData.map((d) => d.code));
|
||||
const countMap = Object.fromEntries(countryData.map((d) => [d.code, d.count]));
|
||||
|
||||
const allItem = makeItem(
|
||||
`<i class="fa-solid fa-earth-americas text-slate-400"></i>`,
|
||||
"All Countries",
|
||||
"",
|
||||
"all",
|
||||
null,
|
||||
currentCode === "all",
|
||||
);
|
||||
if (!query) list.appendChild(allItem);
|
||||
|
||||
const continentGroups = {};
|
||||
|
||||
for (const code of presentCodes) {
|
||||
if (code === "ZZ") continue;
|
||||
const info = COUNTRIES[code];
|
||||
const name = info ? info[0] : code;
|
||||
const continent = info ? info[1] : "Other";
|
||||
|
||||
if (query && !name.toLowerCase().includes(query) && !code.toLowerCase().includes(query)) continue;
|
||||
|
||||
if (!continentGroups[continent]) continentGroups[continent] = [];
|
||||
continentGroups[continent].push({ code, name, count: countMap[code] || 0 });
|
||||
}
|
||||
|
||||
const continentKeys = Object.keys(continentGroups).sort((a, b) => {
|
||||
const ai = CONTINENT_ORDER.indexOf(a);
|
||||
const bi = CONTINENT_ORDER.indexOf(b);
|
||||
if (ai === -1 && bi === -1) return a.localeCompare(b);
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
});
|
||||
|
||||
for (const continent of continentKeys) {
|
||||
const countries = continentGroups[continent].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-slate-500 bg-slate-950/50 sticky top-0";
|
||||
header.textContent = continent;
|
||||
list.appendChild(header);
|
||||
|
||||
for (const { code, name, count } of countries) {
|
||||
const flag = flagHtml(code);
|
||||
const item = makeItem(flag, name, code, code, count, currentCode === code);
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (list.children.length === 0 || (list.children.length === 1 && !query)) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "px-4 py-6 text-center text-slate-500 text-sm";
|
||||
empty.textContent = query ? "No matching countries" : "No country data yet";
|
||||
list.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
function makeItem(flagMarkup, name, code, value, count, active) {
|
||||
const el = document.createElement("button");
|
||||
el.type = "button";
|
||||
el.className = `w-full text-left flex items-center gap-3 px-3 py-2 text-sm hover:bg-slate-800 transition-colors ${active ? "bg-slate-800/80 text-blue-300" : "text-slate-200"}`;
|
||||
|
||||
const flagSpan = document.createElement("span");
|
||||
flagSpan.className = "w-6 text-center flex-shrink-0 text-base";
|
||||
flagSpan.innerHTML = flagMarkup;
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "flex-1 truncate";
|
||||
nameSpan.textContent = name;
|
||||
|
||||
el.appendChild(flagSpan);
|
||||
el.appendChild(nameSpan);
|
||||
|
||||
if (count !== null) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "text-xs text-slate-400 font-mono flex-shrink-0";
|
||||
badge.textContent = count;
|
||||
el.appendChild(badge);
|
||||
}
|
||||
|
||||
el.addEventListener("click", () => setSelected(value, name, flagMarkup));
|
||||
return el;
|
||||
}
|
||||
|
||||
async function loadCountries() {
|
||||
try {
|
||||
const res = await fetch("/api/clients/countries");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
countryData = Array.isArray(data.countries) ? data.countries : [];
|
||||
renderList("");
|
||||
} catch (err) {
|
||||
console.error("country-picker: failed to load countries", err);
|
||||
}
|
||||
}
|
||||
|
||||
loadCountries();
|
||||
setInterval(loadCountries, 30000);
|
||||
|
||||
renderList("");
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export async function loadWithOptions(options = {}) {
|
||||
sort: state.sort,
|
||||
status: state.filterStatus || "all",
|
||||
os: state.filterOs || "all",
|
||||
country: state.filterCountry || "all",
|
||||
});
|
||||
const res = await fetch(`/api/clients?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
requestPreview,
|
||||
requestThumbnail,
|
||||
} from "./data.js";
|
||||
import { initCountryPicker } from "./country-picker.js";
|
||||
|
||||
const grid = document.getElementById("grid");
|
||||
const totalPill = document.getElementById("total-pill");
|
||||
@@ -445,6 +446,13 @@ filterOsSelect?.addEventListener("change", (e) => {
|
||||
loadWithOptions({ force: true });
|
||||
});
|
||||
|
||||
initCountryPicker((code) => {
|
||||
state.filterCountry = code;
|
||||
state.page = 1;
|
||||
state.lastDigest = "";
|
||||
loadWithOptions({ force: true });
|
||||
});
|
||||
|
||||
showOfflineToggle?.addEventListener("change", (e) => {
|
||||
if (e.target.checked) {
|
||||
state.filterStatus = lastNonOnlineStatus || "all";
|
||||
|
||||
@@ -212,6 +212,7 @@ export function createRenderer({
|
||||
card.dataset.os = String(client.os || "").toLowerCase();
|
||||
card.dataset.nickname = String(client.nickname || "");
|
||||
card.dataset.customTag = String(client.customTag || "");
|
||||
card.dataset.bookmarked = String(!!client.bookmarked);
|
||||
card._customTagNote = String(client.customTagNote || "");
|
||||
const os = osBadge(client.os || "unknown");
|
||||
const arch = archBadge(client.arch || "");
|
||||
@@ -226,7 +227,7 @@ export function createRenderer({
|
||||
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
|
||||
const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded;
|
||||
card.dataset.tagNoteExpanded = isTagNoteExpanded ? "true" : "false";
|
||||
card.className = `card rounded-xl border border-slate-800 bg-slate-900/70 p-4 shadow-lg ${client.online ? "" : "card-offline"} tone-${os.tone}`;
|
||||
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 cardThumb = client.thumbnail
|
||||
? (() => {
|
||||
const wrapper = document.createElement("div");
|
||||
@@ -278,6 +279,7 @@ export function createRenderer({
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="bookmark-btn inline-flex items-center justify-center w-9 h-9 rounded-lg border ${client.bookmarked ? "border-yellow-600 bg-yellow-900/50 text-yellow-300" : "border-slate-700 bg-slate-800/50 text-slate-500 hover:text-yellow-300 hover:border-yellow-700"} transition-colors" data-id="${escapeHtml(client.id)}" title="${client.bookmarked ? "Remove bookmark" : "Bookmark"}"><i class="fa-${client.bookmarked ? "solid" : "regular"} fa-star"></i></button>
|
||||
<span class="text-emerald-300 font-mono text-sm inline-flex items-center gap-2"><i class="fa-solid fa-satellite-dish"></i> ${formatPing(client.pingMs)}</span>
|
||||
${isViewer ? "" : `<button class="command-btn inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-800 bg-slate-800/70 hover:bg-slate-700" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-bars"></i> Commands</button>`}
|
||||
${isViewer ? "" : `<button class="ban-btn inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-red-800 bg-red-900/60 hover:bg-red-800 text-red-100" data-id="${escapeHtml(client.id)}"><i class="fa-solid fa-ban"></i> Ban</button>`}
|
||||
@@ -323,6 +325,41 @@ export function createRenderer({
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -5,6 +5,7 @@ export const state = {
|
||||
sort: "last_seen_desc",
|
||||
filterStatus: "all",
|
||||
filterOs: "all",
|
||||
filterCountry: "all",
|
||||
lastDigest: "",
|
||||
isLoading: false,
|
||||
pendingForce: false,
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
>
|
||||
<option value="all">All OS</option>
|
||||
</select>
|
||||
<div id="country-picker-anchor" class="relative"></div>
|
||||
<select
|
||||
id="sort"
|
||||
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-slate-100"
|
||||
@@ -95,6 +96,8 @@
|
||||
<option value="host_asc">Host A→Z</option>
|
||||
<option value="ping_asc">Ping ↑</option>
|
||||
<option value="ping_desc">Ping ↓</option>
|
||||
<option value="country_asc">Country A→Z</option>
|
||||
<option value="country_desc">Country Z→A</option>
|
||||
</select>
|
||||
<button
|
||||
id="select-all"
|
||||
|
||||
@@ -27,7 +27,8 @@ db.run(`
|
||||
country TEXT,
|
||||
last_seen INTEGER,
|
||||
online INTEGER,
|
||||
ping_ms INTEGER
|
||||
ping_ms INTEGER,
|
||||
bookmarked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
try {
|
||||
@@ -48,6 +49,9 @@ try {
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN custom_tag_note TEXT`);
|
||||
} catch {}
|
||||
try {
|
||||
db.run(`ALTER TABLE clients ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch {}
|
||||
db.run(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
|
||||
);
|
||||
@@ -294,6 +298,7 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
sort,
|
||||
statusFilter,
|
||||
osFilter,
|
||||
countryFilter,
|
||||
allowedClientIds,
|
||||
deniedClientIds,
|
||||
} = filters;
|
||||
@@ -319,6 +324,11 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
params.push(osFilter);
|
||||
}
|
||||
|
||||
if (countryFilter && countryFilter !== "all") {
|
||||
where.push("UPPER(COALESCE(country,'ZZ'))=?");
|
||||
params.push(countryFilter.toUpperCase());
|
||||
}
|
||||
|
||||
if (Array.isArray(allowedClientIds)) {
|
||||
if (allowedClientIds.length === 0) {
|
||||
where.push("1=0");
|
||||
@@ -336,17 +346,22 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
|
||||
const orderBy = (() => {
|
||||
const bookmark = "bookmarked DESC";
|
||||
switch (sort) {
|
||||
case "ping_asc":
|
||||
return "ORDER BY ping_ms IS NULL, ping_ms ASC";
|
||||
return `ORDER BY ${bookmark}, ping_ms IS NULL, ping_ms ASC, id ASC`;
|
||||
case "ping_desc":
|
||||
return "ORDER BY ping_ms IS NULL, ping_ms DESC";
|
||||
return `ORDER BY ${bookmark}, ping_ms IS NULL, ping_ms DESC, id ASC`;
|
||||
case "host_asc":
|
||||
return "ORDER BY LOWER(COALESCE(nickname, host)) ASC";
|
||||
return `ORDER BY ${bookmark}, LOWER(COALESCE(nickname, host)) ASC, id ASC`;
|
||||
case "host_desc":
|
||||
return "ORDER BY LOWER(COALESCE(nickname, host)) DESC";
|
||||
return `ORDER BY ${bookmark}, LOWER(COALESCE(nickname, host)) DESC, id ASC`;
|
||||
case "country_asc":
|
||||
return `ORDER BY ${bookmark}, LOWER(COALESCE(country, 'zz')) ASC, id ASC`;
|
||||
case "country_desc":
|
||||
return `ORDER BY ${bookmark}, LOWER(COALESCE(country, 'zz')) DESC, id ASC`;
|
||||
default:
|
||||
return "ORDER BY last_seen DESC";
|
||||
return `ORDER BY ${bookmark}, last_seen DESC, id ASC`;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -362,7 +377,7 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
|
||||
const rows = db
|
||||
.query<any>(
|
||||
`SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs
|
||||
`SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs, bookmarked
|
||||
FROM clients
|
||||
${whereSql}
|
||||
${orderBy}
|
||||
@@ -387,6 +402,7 @@ export function listClients(filters: ListFilters): ListResult {
|
||||
country: c.country || "ZZ",
|
||||
pingMs: c.pingMs ?? null,
|
||||
online: c.online === 1,
|
||||
bookmarked: c.bookmarked === 1,
|
||||
thumbnail: getThumbnail(c.id),
|
||||
}));
|
||||
|
||||
@@ -591,6 +607,32 @@ export function clientExists(id: string): boolean {
|
||||
return !!row?.id;
|
||||
}
|
||||
|
||||
export function listDistinctCountries(): { code: string; count: number }[] {
|
||||
const rows = db
|
||||
.query<{ code: string; count: number }>(
|
||||
`SELECT UPPER(COALESCE(NULLIF(country, ''), 'ZZ')) as code, COUNT(*) as count
|
||||
FROM clients
|
||||
GROUP BY UPPER(COALESCE(NULLIF(country, ''), 'ZZ'))
|
||||
ORDER BY count DESC`,
|
||||
)
|
||||
.all();
|
||||
return rows.map((r) => ({ code: r.code, count: Number(r.count) || 0 }));
|
||||
}
|
||||
|
||||
export function setClientBookmark(id: string, bookmarked: boolean): boolean {
|
||||
const result = db.run(
|
||||
`UPDATE clients SET bookmarked=? WHERE id=?`,
|
||||
bookmarked ? 1 : 0,
|
||||
id,
|
||||
);
|
||||
return ((result as any)?.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export function getClientBookmark(id: string): boolean {
|
||||
const row = db.query<{ bookmarked: number }>(`SELECT bookmarked FROM clients WHERE id=?`).get(id);
|
||||
return row?.bookmarked === 1;
|
||||
}
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
status: string;
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
isIpBanned,
|
||||
listBannedIps,
|
||||
listClients,
|
||||
listDistinctCountries,
|
||||
setClientBookmark,
|
||||
setClientNickname,
|
||||
setClientTag,
|
||||
setOnlineState,
|
||||
@@ -71,8 +73,9 @@ export async function handleClientRoutes(
|
||||
const sort = url.searchParams.get("sort") || "last_seen_desc";
|
||||
const statusFilter = url.searchParams.get("status") || "all";
|
||||
const osFilter = url.searchParams.get("os") || "all";
|
||||
const countryFilter = url.searchParams.get("country") || "all";
|
||||
if (user.role === "admin") {
|
||||
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
|
||||
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter, countryFilter });
|
||||
return Response.json(result, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
@@ -100,12 +103,20 @@ export async function handleClientRoutes(
|
||||
sort,
|
||||
statusFilter,
|
||||
osFilter,
|
||||
countryFilter,
|
||||
allowedClientIds,
|
||||
deniedClientIds,
|
||||
});
|
||||
return Response.json(result, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/clients/countries") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
const countries = listDistinctCountries();
|
||||
return Response.json({ countries }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/clients/banned-ips") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
@@ -398,6 +409,35 @@ export async function handleClientRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const bookmarkMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/bookmark$/);
|
||||
if (req.method === "PATCH" && bookmarkMatch) {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const targetId = bookmarkMatch[1];
|
||||
if (!canUserAccessClient(user.userId, user.role, targetId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
}
|
||||
if (!clientExists(targetId)) {
|
||||
return Response.json({ error: "Client not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const bookmarked = !!body?.bookmarked;
|
||||
const updated = setClientBookmark(targetId, bookmarked);
|
||||
if (!updated) {
|
||||
return Response.json({ error: "Client not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, bookmarked }, { headers: deps.CORS_HEADERS });
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/);
|
||||
if (cmdMatch) {
|
||||
|
||||
@@ -31,6 +31,7 @@ export type ListFilters = {
|
||||
sort: string;
|
||||
statusFilter?: string;
|
||||
osFilter?: string;
|
||||
countryFilter?: string;
|
||||
allowedClientIds?: string[];
|
||||
deniedClientIds?: string[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user