/**
* 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