+326
@@ -0,0 +1,326 @@
|
||||
const els = {
|
||||
statusBadge: document.querySelector('#statusBadge'),
|
||||
serverFilter: document.querySelector('#serverFilter'),
|
||||
visibleCount: document.querySelector('#visibleCount'),
|
||||
totalCount: document.querySelector('#totalCount'),
|
||||
lastUpdated: document.querySelector('#lastUpdated'),
|
||||
nextRefresh: document.querySelector('#nextRefresh'),
|
||||
errorBox: document.querySelector('#errorBox'),
|
||||
rows: document.querySelector('#lobbyRows'),
|
||||
qFilter: document.querySelector('#qFilter'),
|
||||
gameFilter: document.querySelector('#gameFilter'),
|
||||
mapFilter: document.querySelector('#mapFilter'),
|
||||
regionFilter: document.querySelector('#regionFilter'),
|
||||
passFilter: document.querySelector('#passFilter'),
|
||||
sortBy: document.querySelector('#sortBy'),
|
||||
openOnly: document.querySelector('#openOnly'),
|
||||
resetBtn: document.querySelector('#resetBtn'),
|
||||
refreshBtn: document.querySelector('#refreshBtn'),
|
||||
};
|
||||
|
||||
let snapshot = null;
|
||||
let regionsReady = false;
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
function text(value) {
|
||||
return String(value || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '-';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return dateTimeFormat.format(date);
|
||||
}
|
||||
|
||||
function formatCountdown(iso) {
|
||||
if (!iso) return snapshot?.refreshInProgress ? 'refreshing' : '-';
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
if (!Number.isFinite(ms) || ms <= 0) return snapshot?.refreshInProgress ? 'refreshing' : 'soon';
|
||||
const seconds = Math.ceil(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = String(seconds % 60).padStart(2, '0');
|
||||
return `${minutes}:${rest}`;
|
||||
}
|
||||
|
||||
function statusInfo(data) {
|
||||
if (!data) return ['starting', 'badge-muted'];
|
||||
if (data.refreshInProgress) return ['refreshing', 'badge-work'];
|
||||
if (data.connection?.steam === 'auth-required') return ['auth required', 'badge-warn'];
|
||||
if (data.connection?.steam === 'steam-guard') return ['steam guard', 'badge-warn'];
|
||||
if (data.status === 'ready') return ['online', 'badge-ok'];
|
||||
if (data.status === 'stale') return ['stale', 'badge-warn'];
|
||||
if (data.status === 'error') return ['error', 'badge-error'];
|
||||
if (data.connection?.steam !== 'online' || data.connection?.gc !== 'ready') return ['connecting', 'badge-work'];
|
||||
return [data.status || 'starting', 'badge-muted'];
|
||||
}
|
||||
|
||||
function setBadge(label, klass) {
|
||||
els.statusBadge.textContent = label;
|
||||
els.statusBadge.className = `badge ${klass}`;
|
||||
}
|
||||
|
||||
function initRegions(regions) {
|
||||
if (regionsReady || !regions) return;
|
||||
const current = els.regionFilter.value || 'all';
|
||||
els.regionFilter.replaceChildren();
|
||||
|
||||
const all = document.createElement('option');
|
||||
all.value = 'all';
|
||||
all.textContent = 'all';
|
||||
els.regionFilter.append(all);
|
||||
|
||||
Object.entries(regions)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.forEach(([id, name]) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
option.textContent = name;
|
||||
els.regionFilter.append(option);
|
||||
});
|
||||
|
||||
els.regionFilter.value = current;
|
||||
regionsReady = true;
|
||||
}
|
||||
|
||||
function filterLobbies(lobbies) {
|
||||
const q = text(els.qFilter.value);
|
||||
const game = text(els.gameFilter.value);
|
||||
const map = text(els.mapFilter.value);
|
||||
const region = els.regionFilter.value;
|
||||
const pass = els.passFilter.value;
|
||||
const openOnly = els.openOnly.checked;
|
||||
|
||||
return lobbies.filter((row) => {
|
||||
const haystack = text([
|
||||
row.game,
|
||||
row.lobby,
|
||||
row.map,
|
||||
row.region,
|
||||
row.leader,
|
||||
row.customGameId,
|
||||
row.leaderAccountId,
|
||||
].join(' '));
|
||||
|
||||
if (q && !haystack.includes(q)) return false;
|
||||
if (game && !text(row.game).includes(game)) return false;
|
||||
if (map && !text(row.map).includes(map)) return false;
|
||||
if (region !== 'all' && String(row.regionId) !== region) return false;
|
||||
if (pass === 'yes' && !row.hasPassKey) return false;
|
||||
if (pass === 'no' && row.hasPassKey) return false;
|
||||
if (openOnly && Number(row.openSlots) <= 0) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortLobbies(rows) {
|
||||
const sorted = [...rows];
|
||||
const byName = (a, b, key) => String(a[key] || '').localeCompare(String(b[key] || ''), 'en');
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
if (els.sortBy.value === 'open-desc') {
|
||||
return Number(b.openSlots) - Number(a.openSlots) || byName(a, b, 'game');
|
||||
}
|
||||
if (els.sortBy.value === 'game-asc') {
|
||||
return byName(a, b, 'game') || Number(b.memberCount) - Number(a.memberCount);
|
||||
}
|
||||
if (els.sortBy.value === 'region-asc') {
|
||||
return byName(a, b, 'region') || byName(a, b, 'game');
|
||||
}
|
||||
return Number(b.memberCount) - Number(a.memberCount) || byName(a, b, 'game');
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function makeCell(value, className) {
|
||||
const td = document.createElement('td');
|
||||
if (className) td.className = className;
|
||||
td.textContent = value || '-';
|
||||
return td;
|
||||
}
|
||||
|
||||
function renderRows(rows, totalLoaded) {
|
||||
els.rows.replaceChildren();
|
||||
|
||||
if (!rows.length) {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 7;
|
||||
td.className = 'empty';
|
||||
td.textContent = totalLoaded ? 'No lobbies match the selected filters' : 'No data yet';
|
||||
tr.append(td);
|
||||
els.rows.append(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const game = document.createElement('td');
|
||||
game.className = 'game';
|
||||
const gameName = document.createElement('div');
|
||||
gameName.textContent = row.game || row.customGameId || '-';
|
||||
game.append(gameName);
|
||||
if (row.customGameId && row.game !== row.customGameId) {
|
||||
const id = document.createElement('div');
|
||||
id.className = 'muted';
|
||||
id.textContent = row.customGameId;
|
||||
game.append(id);
|
||||
}
|
||||
|
||||
const players = document.createElement('td');
|
||||
players.className = 'num';
|
||||
const playersPill = document.createElement('span');
|
||||
playersPill.className = 'pill';
|
||||
playersPill.textContent = row.players || '-';
|
||||
players.append(playersPill);
|
||||
|
||||
const pass = document.createElement('td');
|
||||
const passPill = document.createElement('span');
|
||||
passPill.className = row.hasPassKey ? 'pill pass' : 'muted';
|
||||
passPill.textContent = row.hasPassKey ? 'yes' : 'no';
|
||||
pass.append(passPill);
|
||||
|
||||
tr.append(
|
||||
game,
|
||||
makeCell(row.lobby),
|
||||
makeCell(row.map),
|
||||
players,
|
||||
makeCell(row.region),
|
||||
pass,
|
||||
makeCell(row.leader),
|
||||
);
|
||||
fragment.append(tr);
|
||||
}
|
||||
|
||||
els.rows.append(fragment);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const data = snapshot;
|
||||
const lobbies = data?.lobbies || [];
|
||||
const filtered = sortLobbies(filterLobbies(lobbies));
|
||||
const [label, klass] = statusInfo(data);
|
||||
|
||||
setBadge(label, klass);
|
||||
els.visibleCount.textContent = String(filtered.length);
|
||||
els.totalCount.textContent = String(data?.totalLobbies || lobbies.length || 0);
|
||||
els.lastUpdated.textContent = formatDate(data?.lastUpdatedAt);
|
||||
els.nextRefresh.textContent = formatCountdown(data?.nextRefreshAt);
|
||||
els.refreshBtn.disabled = Boolean(data?.refreshInProgress);
|
||||
els.refreshBtn.textContent = data?.refreshInProgress ? 'Refreshing' : 'Refresh';
|
||||
|
||||
if (data?.serverFilters) {
|
||||
const parts = [`GC: ${data.serverFilters.regionName}`];
|
||||
if (data.serverFilters.customGameId) parts.push(`game ${data.serverFilters.customGameId}`);
|
||||
els.serverFilter.textContent = parts.join(', ');
|
||||
}
|
||||
|
||||
if (data?.lastError) {
|
||||
els.errorBox.hidden = false;
|
||||
els.errorBox.textContent = data.lastError;
|
||||
} else {
|
||||
els.errorBox.hidden = true;
|
||||
els.errorBox.textContent = '';
|
||||
}
|
||||
|
||||
renderRows(filtered, lobbies.length);
|
||||
}
|
||||
|
||||
function setSnapshot(data) {
|
||||
snapshot = data;
|
||||
initRegions(data?.regions);
|
||||
render();
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
const res = await fetch('/api/state', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSnapshot(await res.json());
|
||||
}
|
||||
|
||||
async function refreshNow() {
|
||||
els.refreshBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/refresh', { method: 'POST', cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSnapshot(await res.json());
|
||||
} catch (err) {
|
||||
if (snapshot) {
|
||||
snapshot.lastError = err.message;
|
||||
render();
|
||||
} else {
|
||||
els.errorBox.hidden = false;
|
||||
els.errorBox.textContent = err.message;
|
||||
}
|
||||
} finally {
|
||||
els.refreshBtn.disabled = Boolean(snapshot?.refreshInProgress);
|
||||
}
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (!window.EventSource) return;
|
||||
const events = new EventSource('/events');
|
||||
events.onmessage = (event) => {
|
||||
try {
|
||||
setSnapshot(JSON.parse(event.data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
els.qFilter,
|
||||
els.gameFilter,
|
||||
els.mapFilter,
|
||||
els.regionFilter,
|
||||
els.passFilter,
|
||||
els.sortBy,
|
||||
els.openOnly,
|
||||
].forEach((el) => {
|
||||
el.addEventListener('input', render);
|
||||
el.addEventListener('change', render);
|
||||
});
|
||||
|
||||
els.resetBtn.addEventListener('click', () => {
|
||||
els.qFilter.value = '';
|
||||
els.gameFilter.value = '';
|
||||
els.mapFilter.value = '';
|
||||
els.regionFilter.value = 'all';
|
||||
els.passFilter.value = 'all';
|
||||
els.sortBy.value = 'players-desc';
|
||||
els.openOnly.checked = false;
|
||||
render();
|
||||
});
|
||||
|
||||
els.refreshBtn.addEventListener('click', refreshNow);
|
||||
|
||||
setInterval(() => {
|
||||
if (snapshot) els.nextRefresh.textContent = formatCountdown(snapshot.nextRefreshAt);
|
||||
}, 1000);
|
||||
|
||||
setInterval(() => {
|
||||
fetchState().catch((err) => {
|
||||
if (!snapshot) {
|
||||
els.errorBox.hidden = false;
|
||||
els.errorBox.textContent = err.message;
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
connectEvents();
|
||||
fetchState().catch((err) => {
|
||||
els.errorBox.hidden = false;
|
||||
els.errorBox.textContent = err.message;
|
||||
});
|
||||
Reference in New Issue
Block a user