mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-05 04:13:44 +00:00
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
17 KiB
JavaScript
393 lines
17 KiB
JavaScript
/**
|
|
* 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, '"').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;
|
|
});
|
|
};
|
|
|
|
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() : '-'; },
|
|
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 '<span class="badge text-bg-' + type + '">' + esc(text) + '</span>'; },
|
|
link: function (href, text) { return '<a href="' + esc(href) + '">' + esc(text) + '</a>'; },
|
|
// 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 '<span class="text-body-secondary">-</span>';
|
|
return name ? fmt.link('/characters/' + id, name) : '<span class="text-body-secondary" title="Character not found">Unknown (' + esc(id) + ')</span>';
|
|
},
|
|
zone: function (id, name) {
|
|
return esc(name || ('Zone ' + id)) + ' <span class="small text-body-secondary">(' + esc(id) + ')</span>';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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 <body data-prefs>, 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
|
|
* <body data-labels>: 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 <select>: type to search, pick with the mouse or the arrow keys and Enter.
|
|
* SearchSelect(input, {search: text => Promise of [{value, label, detail}], value, label, onPick(item)}).
|
|
* The picked value is on input.dataset.value (empty until something is picked); the box shows its label.
|
|
* Returns {input, set(value, label)}: set changes the pick from code (nothing: clears it).
|
|
*/
|
|
window.SearchSelect = function (input, options) {
|
|
var wrap = document.createElement('div');
|
|
wrap.className = 'position-relative';
|
|
input.parentNode.insertBefore(wrap, input);
|
|
wrap.appendChild(input);
|
|
var menu = document.createElement('div');
|
|
menu.className = 'dropdown-menu w-100 overflow-auto';
|
|
menu.style.maxHeight = '18rem';
|
|
wrap.appendChild(menu);
|
|
input.setAttribute('autocomplete', 'off');
|
|
input.setAttribute('role', 'combobox');
|
|
input.dataset.value = options.value || '';
|
|
input.value = options.label || '';
|
|
var items = [], active = -1, timer = null, latest = 0, picked = input.value;
|
|
|
|
function show(open) { menu.classList.toggle('show', open); input.setAttribute('aria-expanded', open ? 'true' : 'false'); }
|
|
function highlight(i) {
|
|
active = i;
|
|
Array.prototype.forEach.call(menu.children, function (el, n) { el.classList.toggle('active', n === i); if (n === i) el.scrollIntoView({ block: 'nearest' }); });
|
|
}
|
|
function render(list) {
|
|
items = list;
|
|
menu.innerHTML = list.length ? list.map(function (item, n) {
|
|
return '<button type="button" class="dropdown-item text-wrap" data-n="' + n + '">' + esc(item.label) +
|
|
(item.detail ? '<div class="small text-body-secondary">' + esc(item.detail) + '</div>' : '') + '</button>';
|
|
}).join('') : '<div class="dropdown-item-text small text-body-secondary">No matches</div>';
|
|
highlight(list.length ? 0 : -1);
|
|
show(true);
|
|
}
|
|
function lookup() {
|
|
var request = ++latest;
|
|
options.search(input.value.trim()).then(function (list) { if (request === latest && document.activeElement === input) render(list || []); }).catch(function () {});
|
|
}
|
|
function pick(item) {
|
|
input.dataset.value = item.value;
|
|
input.value = picked = item.label;
|
|
show(false);
|
|
if (options.onPick) options.onPick(item);
|
|
}
|
|
|
|
input.addEventListener('focus', function () { input.select(); lookup(); });
|
|
input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(lookup, 200); });
|
|
input.addEventListener('keydown', function (e) {
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (!menu.classList.contains('show')) return lookup();
|
|
if (items.length) highlight((active + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length);
|
|
} else if (e.key === 'Enter' && menu.classList.contains('show')) {
|
|
e.preventDefault();
|
|
if (items[active]) pick(items[active]);
|
|
} else if (e.key === 'Escape') {
|
|
show(false);
|
|
}
|
|
});
|
|
// Leaving the box without picking puts back what was picked before
|
|
input.addEventListener('blur', function () { setTimeout(function () { show(false); input.value = picked; }, 150); });
|
|
menu.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
|
menu.addEventListener('click', function (e) {
|
|
var el = e.target.closest('[data-n]');
|
|
if (el) pick(items[Number(el.dataset.n)]);
|
|
});
|
|
return {
|
|
input: input,
|
|
set: function (value, label) { input.dataset.value = value || ''; input.value = picked = label || ''; }
|
|
};
|
|
};
|
|
|
|
window.Prefs = (function () {
|
|
var saved = {};
|
|
try { saved = JSON.parse(document.body.dataset.prefs || '{}') || {}; } catch (e) {}
|
|
var pending = {}, timer = null;
|
|
function flush() {
|
|
timer = null;
|
|
var changes = pending;
|
|
pending = {};
|
|
// Public pages (the showcase) are also seen by visitors who aren't signed in: nothing to save for them
|
|
if (!document.body.dataset.username) return;
|
|
api.post('/api/account/preferences', changes).catch(function () {});
|
|
}
|
|
function set(name, value) {
|
|
if (saved[name] === value) return;
|
|
saved[name] = value;
|
|
pending[name] = value;
|
|
if (!timer) timer = setTimeout(flush, 600);
|
|
}
|
|
function valueOf(input) {
|
|
if (input.type === 'checkbox') return input.checked;
|
|
if (input.type === 'radio') {
|
|
var picked = document.querySelector('input[type=radio][name="' + input.name + '"]:checked');
|
|
return picked ? picked.value : null;
|
|
}
|
|
return input.value;
|
|
}
|
|
function apply(input) {
|
|
var name = input.dataset.pref;
|
|
if (!(name in saved)) return;
|
|
var value = saved[name];
|
|
if (input.type === 'checkbox') input.checked = !!value;
|
|
else if (input.type === 'radio') input.checked = input.value === String(value);
|
|
else if (input.tagName !== 'SELECT' || input.querySelector('option[value="' + CSS.escape(String(value)) + '"]')) input.value = value;
|
|
}
|
|
document.querySelectorAll('[data-pref]').forEach(apply);
|
|
document.addEventListener('change', function (e) {
|
|
var input = e.target.closest && e.target.closest('[data-pref]');
|
|
if (input) set(input.dataset.pref, valueOf(input));
|
|
});
|
|
// Don't lose a change made just before leaving the page
|
|
window.addEventListener('pagehide', function () {
|
|
if (!timer) return;
|
|
clearTimeout(timer);
|
|
fetch('/api/account/preferences', { method: 'POST', keepalive: true, credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dashboard' }, body: JSON.stringify(pending) });
|
|
pending = {};
|
|
timer = null;
|
|
});
|
|
return {
|
|
get: function (name, fallback) { return name in saved ? saved[name] : fallback; },
|
|
set: set
|
|
};
|
|
})();
|
|
|
|
/**
|
|
* Layout helpers (dashboard.css). --topbar-h is the height of the sticky top bar, for things that stick below it.
|
|
* .fill-viewport[-lg|-xl] get --fill-top: where they start on the page plus what comes after the page content (its
|
|
* bottom padding and the footer), so they reach down to the bottom of the window at the top of the page.
|
|
* Measured again when the window, the page content or the open tab changes.
|
|
*/
|
|
(function () {
|
|
var root = document.documentElement;
|
|
var queued = false;
|
|
function measure() {
|
|
queued = false;
|
|
var bar = document.querySelector('header.sticky-top');
|
|
root.style.setProperty('--topbar-h', (bar ? bar.offsetHeight : 0) + 'px');
|
|
var fills = document.querySelectorAll('.fill-viewport, .fill-viewport-lg, .fill-viewport-xl');
|
|
var main = document.querySelector('main');
|
|
if (!fills.length || !main) return;
|
|
var scroll = window.scrollY;
|
|
var after = parseFloat(getComputedStyle(main).paddingBottom || 0);
|
|
for (var next = main.nextElementSibling; next; next = next.nextElementSibling) after += next.offsetHeight;
|
|
fills.forEach(function (el) {
|
|
if (!el.offsetParent) return; // in a hidden tab: measured when it opens
|
|
var top = el.getBoundingClientRect().top + scroll;
|
|
el.style.setProperty('--fill-top', Math.max(0, Math.round(top + after)) + 'px');
|
|
});
|
|
}
|
|
function queue() {
|
|
if (queued) return;
|
|
queued = true;
|
|
requestAnimationFrame(measure);
|
|
}
|
|
window.fitLayout = measure;
|
|
measure();
|
|
window.addEventListener('resize', queue);
|
|
window.addEventListener('load', queue);
|
|
document.addEventListener('shown.bs.tab', queue);
|
|
document.addEventListener('shown.bs.collapse', queue);
|
|
document.addEventListener('hidden.bs.collapse', queue);
|
|
document.addEventListener('toggle', queue, true); // <details>
|
|
if (window.ResizeObserver) {
|
|
var main = document.querySelector('main');
|
|
if (main) new ResizeObserver(queue).observe(main);
|
|
}
|
|
})();
|
|
|
|
/**
|
|
* A .collapse marked data-pref-open="page.name" (a folding card's body) opens or stays folded as it was last left.
|
|
*/
|
|
document.querySelectorAll('.collapse[data-pref-open]').forEach(function (el) {
|
|
var open = Prefs.get(el.dataset.prefOpen, null);
|
|
if (open === null) return;
|
|
el.classList.toggle('show', !!open);
|
|
document.querySelectorAll('[data-bs-target="#' + CSS.escape(el.id) + '"]').forEach(function (b) {
|
|
b.classList.toggle('collapsed', !open);
|
|
b.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
});
|
|
});
|
|
document.addEventListener('shown.bs.collapse', function (e) { if (e.target.dataset.prefOpen) Prefs.set(e.target.dataset.prefOpen, true); });
|
|
document.addEventListener('hidden.bs.collapse', function (e) { if (e.target.dataset.prefOpen) Prefs.set(e.target.dataset.prefOpen, false); });
|
|
|
|
if (window.jQuery && $.fn.dataTable) {
|
|
// Columns without a custom renderer are inserted as HTML by default; render them as text instead
|
|
$.extend(true, $.fn.dataTable.defaults, {
|
|
columnDefs: [{ targets: '_all', render: $.fn.dataTable.render.text() }]
|
|
});
|
|
$.ajaxSetup({ headers: { 'X-Requested-With': 'dashboard' } });
|
|
$(document).on('ajaxError', function (e, xhr) { if (xhr.status === 401) window.location.href = '/login'; });
|
|
|
|
/**
|
|
* Server-side DataTable backed by one of the /api/tables endpoints.
|
|
* @param extra Optional function returning extra fields to send with each request (filters)
|
|
* @param liveTable Name of the table in table_changed events, to reload when it changes
|
|
*/
|
|
window.serverTable = function (selector, url, columns, options) {
|
|
options = options || {};
|
|
var stack = $(selector).hasClass('table-stack');
|
|
var labels = stack ? $(selector).find('thead th').map(function () { return $(this).text().trim(); }).get() : [];
|
|
if (stack) columns = columns.map(function (column, i) {
|
|
return $.extend({}, column, { createdCell: function (td) { td.setAttribute('data-label', labels[i] || ''); } });
|
|
});
|
|
// Links like /activity_log#search=123 open with that search filled in
|
|
var initialSearch = (window.location.hash.match(/^#search=(.+)$/) || [])[1];
|
|
var table = $(selector).DataTable($.extend({
|
|
search: { search: initialSearch ? decodeURIComponent(initialSearch) : '' },
|
|
processing: true,
|
|
serverSide: true,
|
|
pageLength: 25,
|
|
lengthMenu: [10, 25, 50, 100],
|
|
order: [[0, 'desc']],
|
|
language: { searchPlaceholder: 'Name or ID' },
|
|
ajax: {
|
|
url: url,
|
|
type: 'POST',
|
|
contentType: 'application/json',
|
|
data: function (d) { return JSON.stringify($.extend(d, options.extra ? options.extra() : {})); }
|
|
},
|
|
columns: columns
|
|
}, options.dataTable || {}));
|
|
if (options.liveTable && window.Live) Live.watchTable(options.liveTable, table);
|
|
return table;
|
|
};
|
|
}
|
|
})();
|