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,
|
sort: state.sort,
|
||||||
status: state.filterStatus || "all",
|
status: state.filterStatus || "all",
|
||||||
os: state.filterOs || "all",
|
os: state.filterOs || "all",
|
||||||
|
country: state.filterCountry || "all",
|
||||||
});
|
});
|
||||||
const res = await fetch(`/api/clients?${params.toString()}`);
|
const res = await fetch(`/api/clients?${params.toString()}`);
|
||||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
requestPreview,
|
requestPreview,
|
||||||
requestThumbnail,
|
requestThumbnail,
|
||||||
} from "./data.js";
|
} from "./data.js";
|
||||||
|
import { initCountryPicker } from "./country-picker.js";
|
||||||
|
|
||||||
const grid = document.getElementById("grid");
|
const grid = document.getElementById("grid");
|
||||||
const totalPill = document.getElementById("total-pill");
|
const totalPill = document.getElementById("total-pill");
|
||||||
@@ -445,6 +446,13 @@ filterOsSelect?.addEventListener("change", (e) => {
|
|||||||
loadWithOptions({ force: true });
|
loadWithOptions({ force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initCountryPicker((code) => {
|
||||||
|
state.filterCountry = code;
|
||||||
|
state.page = 1;
|
||||||
|
state.lastDigest = "";
|
||||||
|
loadWithOptions({ force: true });
|
||||||
|
});
|
||||||
|
|
||||||
showOfflineToggle?.addEventListener("change", (e) => {
|
showOfflineToggle?.addEventListener("change", (e) => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
state.filterStatus = lastNonOnlineStatus || "all";
|
state.filterStatus = lastNonOnlineStatus || "all";
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export function createRenderer({
|
|||||||
card.dataset.os = String(client.os || "").toLowerCase();
|
card.dataset.os = String(client.os || "").toLowerCase();
|
||||||
card.dataset.nickname = String(client.nickname || "");
|
card.dataset.nickname = String(client.nickname || "");
|
||||||
card.dataset.customTag = String(client.customTag || "");
|
card.dataset.customTag = String(client.customTag || "");
|
||||||
|
card.dataset.bookmarked = String(!!client.bookmarked);
|
||||||
card._customTagNote = String(client.customTagNote || "");
|
card._customTagNote = String(client.customTagNote || "");
|
||||||
const os = osBadge(client.os || "unknown");
|
const os = osBadge(client.os || "unknown");
|
||||||
const arch = archBadge(client.arch || "");
|
const arch = archBadge(client.arch || "");
|
||||||
@@ -226,7 +227,7 @@ export function createRenderer({
|
|||||||
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
|
const hasTagNote = customTag.length > 0 && customTagNote.length > 0;
|
||||||
const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded;
|
const isTagNoteExpanded = hasTagNote && wasTagNoteExpanded;
|
||||||
card.dataset.tagNoteExpanded = isTagNoteExpanded ? "true" : "false";
|
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 cardThumb = client.thumbnail
|
||||||
? (() => {
|
? (() => {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
@@ -278,6 +279,7 @@ export function createRenderer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<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>
|
<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="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>`}
|
${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) => {
|
card.querySelector(".client-tag-toggle")?.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const notePanel = card.querySelector(".client-tag-note");
|
const notePanel = card.querySelector(".client-tag-note");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const state = {
|
|||||||
sort: "last_seen_desc",
|
sort: "last_seen_desc",
|
||||||
filterStatus: "all",
|
filterStatus: "all",
|
||||||
filterOs: "all",
|
filterOs: "all",
|
||||||
|
filterCountry: "all",
|
||||||
lastDigest: "",
|
lastDigest: "",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
pendingForce: false,
|
pendingForce: false,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
>
|
>
|
||||||
<option value="all">All OS</option>
|
<option value="all">All OS</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div id="country-picker-anchor" class="relative"></div>
|
||||||
<select
|
<select
|
||||||
id="sort"
|
id="sort"
|
||||||
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-slate-100"
|
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="host_asc">Host A→Z</option>
|
||||||
<option value="ping_asc">Ping ↑</option>
|
<option value="ping_asc">Ping ↑</option>
|
||||||
<option value="ping_desc">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>
|
</select>
|
||||||
<button
|
<button
|
||||||
id="select-all"
|
id="select-all"
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ db.run(`
|
|||||||
country TEXT,
|
country TEXT,
|
||||||
last_seen INTEGER,
|
last_seen INTEGER,
|
||||||
online INTEGER,
|
online INTEGER,
|
||||||
ping_ms INTEGER
|
ping_ms INTEGER,
|
||||||
|
bookmarked INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
try {
|
try {
|
||||||
@@ -48,6 +49,9 @@ try {
|
|||||||
try {
|
try {
|
||||||
db.run(`ALTER TABLE clients ADD COLUMN custom_tag_note TEXT`);
|
db.run(`ALTER TABLE clients ADD COLUMN custom_tag_note TEXT`);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
db.run(`ALTER TABLE clients ADD COLUMN bookmarked INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
} catch {}
|
||||||
db.run(
|
db.run(
|
||||||
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
|
`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,
|
sort,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
osFilter,
|
osFilter,
|
||||||
|
countryFilter,
|
||||||
allowedClientIds,
|
allowedClientIds,
|
||||||
deniedClientIds,
|
deniedClientIds,
|
||||||
} = filters;
|
} = filters;
|
||||||
@@ -319,6 +324,11 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
params.push(osFilter);
|
params.push(osFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (countryFilter && countryFilter !== "all") {
|
||||||
|
where.push("UPPER(COALESCE(country,'ZZ'))=?");
|
||||||
|
params.push(countryFilter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(allowedClientIds)) {
|
if (Array.isArray(allowedClientIds)) {
|
||||||
if (allowedClientIds.length === 0) {
|
if (allowedClientIds.length === 0) {
|
||||||
where.push("1=0");
|
where.push("1=0");
|
||||||
@@ -336,17 +346,22 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
|
||||||
const orderBy = (() => {
|
const orderBy = (() => {
|
||||||
|
const bookmark = "bookmarked DESC";
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case "ping_asc":
|
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":
|
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":
|
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":
|
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:
|
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
|
const rows = db
|
||||||
.query<any>(
|
.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
|
FROM clients
|
||||||
${whereSql}
|
${whereSql}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
@@ -387,6 +402,7 @@ export function listClients(filters: ListFilters): ListResult {
|
|||||||
country: c.country || "ZZ",
|
country: c.country || "ZZ",
|
||||||
pingMs: c.pingMs ?? null,
|
pingMs: c.pingMs ?? null,
|
||||||
online: c.online === 1,
|
online: c.online === 1,
|
||||||
|
bookmarked: c.bookmarked === 1,
|
||||||
thumbnail: getThumbnail(c.id),
|
thumbnail: getThumbnail(c.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -591,6 +607,32 @@ export function clientExists(id: string): boolean {
|
|||||||
return !!row?.id;
|
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 {
|
export interface BuildRecord {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
isIpBanned,
|
isIpBanned,
|
||||||
listBannedIps,
|
listBannedIps,
|
||||||
listClients,
|
listClients,
|
||||||
|
listDistinctCountries,
|
||||||
|
setClientBookmark,
|
||||||
setClientNickname,
|
setClientNickname,
|
||||||
setClientTag,
|
setClientTag,
|
||||||
setOnlineState,
|
setOnlineState,
|
||||||
@@ -71,8 +73,9 @@ export async function handleClientRoutes(
|
|||||||
const sort = url.searchParams.get("sort") || "last_seen_desc";
|
const sort = url.searchParams.get("sort") || "last_seen_desc";
|
||||||
const statusFilter = url.searchParams.get("status") || "all";
|
const statusFilter = url.searchParams.get("status") || "all";
|
||||||
const osFilter = url.searchParams.get("os") || "all";
|
const osFilter = url.searchParams.get("os") || "all";
|
||||||
|
const countryFilter = url.searchParams.get("country") || "all";
|
||||||
if (user.role === "admin") {
|
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 });
|
return Response.json(result, { headers: deps.CORS_HEADERS });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +103,20 @@ export async function handleClientRoutes(
|
|||||||
sort,
|
sort,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
osFilter,
|
osFilter,
|
||||||
|
countryFilter,
|
||||||
allowedClientIds,
|
allowedClientIds,
|
||||||
deniedClientIds,
|
deniedClientIds,
|
||||||
});
|
});
|
||||||
return Response.json(result, { headers: deps.CORS_HEADERS });
|
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") {
|
if (url.pathname === "/api/clients/banned-ips") {
|
||||||
const user = await authenticateRequest(req);
|
const user = await authenticateRequest(req);
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 });
|
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") {
|
if (req.method === "POST") {
|
||||||
const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/);
|
const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/);
|
||||||
if (cmdMatch) {
|
if (cmdMatch) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type ListFilters = {
|
|||||||
sort: string;
|
sort: string;
|
||||||
statusFilter?: string;
|
statusFilter?: string;
|
||||||
osFilter?: string;
|
osFilter?: string;
|
||||||
|
countryFilter?: string;
|
||||||
allowedClientIds?: string[];
|
allowedClientIds?: string[];
|
||||||
deniedClientIds?: string[];
|
deniedClientIds?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user