Files
doka2-lobby-list/public/app.js
T
ab 33e01a4a8f
Docker image / build-and-push (push) Successful in 3m3s
Fixed reconnect
2026-07-22 17:35:23 +03:00

329 lines
9.7 KiB
JavaScript

const els = {
statusBadge: document.querySelector('#statusBadge'),
appVersion: document.querySelector('#appVersion'),
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.appVersion.textContent = data?.version ? `v${data.version}` : '';
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;
});