/** * Shared helpers for every dashboard page. * * Authentication uses an HttpOnly session cookie, so scripts never see the token. State-changing * requests must send X-Requested-With, which the server requires for cookie-authenticated POSTs (CSRF). */ (function () { 'use strict'; var body = document.body; window.DASH = { username: body.getAttribute('data-username') || '', gmLevel: parseInt(body.getAttribute('data-gm-level') || '0', 10) || 0, accountId: parseInt(body.getAttribute('data-account-id') || '0', 10) || 0, permissions: (body.getAttribute('data-can') || '').split(' ').filter(Boolean) }; // Whether the user has a permission (the server checks again; this only hides what would be refused) DASH.can = function (permission) { return DASH.permissions.indexOf(permission) !== -1; }; // Escape a value for insertion into HTML. Every player-controlled string must go through this. window.esc = function (v) { return String(v === null || v === undefined ? '' : v) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); }; function handleResponse(r) { if (r.status === 401) { window.location.href = '/login'; return Promise.reject(new Error('Not signed in')); } var type = r.headers.get('Content-Type') || ''; if (type.indexOf('application/json') === -1) return r.text().then(function (t) { return { success: r.ok, text: t }; }); return r.json().then(function (data) { if (!r.ok && data.success === undefined) data.success = false; return data; }); } window.api = { get: function (url) { return fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'dashboard' } }).then(handleResponse); }, post: function (url, data) { return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dashboard' }, body: JSON.stringify(data || {}) }).then(handleResponse); }, // POST and show the outcome as a toast. Resolves with the response when it succeeded. // Responses carrying a requestId started a live action on online players; its result is toasted too. action: function (url, data, successMessage) { return api.post(url, data).then(function (d) { if (!d.success) { toast(d.error || 'Request failed', 'danger'); return Promise.reject(new Error(d.error || 'Request failed')); } if (successMessage) toast(successMessage, 'success'); // A strike that reached a strike threshold warned, muted or banned the account on its own if (d.strikeStep) toast(d.strikeStep, 'warning'); if (d.strikeStepRequestId && window.Live) { Live.waitForAction(d.strikeStepRequestId).then(function (r) { if (r.message) toast(r.message, r.success ? 'info' : 'danger'); }); } if (d.requestId && window.Live) { d.result = Live.waitForAction(d.requestId).then(function (r) { if (r.message) toast(r.message, r.success ? 'info' : 'danger'); return r; }); } return d; }); } }; /** * Start background work (POST or GET) and wait for its result. Resolves with the result's data; rejects (after * a toast) if starting it or the work itself failed. */ api.job = function (url, data, method) { var start = method === 'GET' ? api.get(url) : api.post(url, data); return start.then(function (d) { if (!d.success || !d.requestId) { toast(d.error || 'Request failed', 'danger'); return Promise.reject(new Error(d.error || 'Request failed')); } return Live.waitForResult(d.requestId); }).then(function (r) { if (!r.success) { toast(r.message || 'Failed', 'danger'); return Promise.reject(new Error(r.message || 'Failed')); } return r.data === undefined ? r : r.data; }); }; /** * Show this page again with what the server has now, after a change made from it: in place when nav.js is there * (Nav.refresh), otherwise a reload. goTo(url) opens another page the same way (Nav.go, or a normal load). */ window.reloadInPlace = function () { if (window.Nav && Nav.refresh) return Nav.refresh({ force: true }); window.location.reload(); return Promise.resolve(); }; window.goTo = function (url, options) { if (window.Nav && Nav.go) return Nav.go(url, options); if (options && options.replace) window.location.replace(url); else window.location.href = url; return Promise.resolve(); }; window.toast = function (message, type) { var container = document.getElementById('toast-container'); if (!container) { container = document.createElement('div'); container.id = 'toast-container'; container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; document.body.appendChild(container); } var el = document.createElement('div'); el.className = 'toast align-items-center text-bg-' + (type || 'secondary') + ' border-0'; el.setAttribute('role', 'status'); var inner = document.createElement('div'); inner.className = 'd-flex'; var text = document.createElement('div'); text.className = 'toast-body'; text.textContent = message; var close = document.createElement('button'); close.type = 'button'; close.className = 'btn-close btn-close-white me-2 m-auto'; close.setAttribute('data-bs-dismiss', 'toast'); inner.append(text, close); el.appendChild(inner); container.appendChild(el); var t = new bootstrap.Toast(el, { delay: 4000 }); el.addEventListener('hidden.bs.toast', function () { el.remove(); }); t.show(); }; // Formatting helpers used by table renderers window.fmt = { unix: function (ts) { return ts ? new Date(ts * 1000).toLocaleString() : '-'; }, // 1536 -> "1.5 KB" bytes: function (n) { n = Number(n) || 0; var units = ['B', 'KB', 'MB', 'GB', 'TB'], i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } return (i === 0 ? n : n.toFixed(n < 10 ? 1 : 0)) + ' ' + units[i]; }, // Seconds -> "3d 4h", "2h 5m", "7m", "12s" duration: function (s) { s = Math.max(0, Math.floor(Number(s) || 0)); var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); if (d) return d + 'd ' + h + 'h'; if (h) return h + 'h ' + m + 'm'; return m ? m + 'm' : s + 's'; }, date: function (s) { if (!s) return '-'; var d = new Date(String(s).replace(' ', 'T') + (String(s).indexOf('Z') === -1 && String(s).indexOf('T') === -1 ? 'Z' : '')); return isNaN(d) ? esc(s) : d.toLocaleString(); }, badge: function (text, type) { return '' + esc(text) + ''; }, link: function (href, text) { return '' + esc(text) + ''; }, // A property by name, linked to its page, with a button straight to its 3D view property: function (id, name) { if (!id || id === '0') return '-'; return '' + fmt.link('/properties/' + id, name || '(unnamed)') + ' 3D'; }, // A character by name, linked; the ID only when the name is unknown (e.g. a deleted character) character: function (id, name) { if (!id || id === '0') return '-'; return name ? fmt.link('/characters/' + id, name) : 'Unknown (' + esc(id) + ')'; }, // A pet's kind: its icon and CDClient name (rows from the pet name tables carry lot and kind) pet: function (lot, kind) { if (!lot) return 'Unknown'; return '' + esc(kind || ('LOT ' + lot)) + ' (' + esc(lot) + ')'; }, // A zone's name (the locale's, in the viewer's language, when it has one) and ID zone: function (id, name) { var known = window.GameText && GameText.zones()[String(id)]; return esc(known || name || ('Zone ' + id)) + ' (' + esc(id) + ')'; } }; /** * View choices saved on the account: an input marked data-pref="page.name" (checkbox, radio group, select or text) * starts as it was left and is saved when changed. The saved values come on , so they're in place * before the page's own script reads the inputs. Prefs.get/set do the same for choices that aren't a form input. */ /** * Names for the game's numbered values (gmLevels, inventories, privacy), from the server's enums on * : Labels.name('privacy', 2) is "Public"; Labels.list('gmLevels') is [{value, name}]. */ window.Labels = (function () { var all = {}; try { all = JSON.parse(document.body.dataset.labels || '{}') || {}; } catch (e) {} return { list: function (kind) { return all[kind] || []; }, name: function (kind, value) { var match = (all[kind] || []).filter(function (l) { return String(l.value) === String(value); })[0]; return match ? match.name : null; } }; })(); /** * A search box in place of a long