/**
* The UGC Server page: counts, the UGC server's live status, and what it made: player models, and cars and rockets as
* assemblies (one per combination of modules, however many builds use it), paged on the server with a search box
* (field prefixes), filters and sorting, all kept in the address so Back and links work. An item opens with its
* generated mesh and LXFML (models), its modules and the builds that use it (assemblies), and the icon editor
* (ugc-icon-editor.js). Everything from the UGC server comes through the dashboard, so the browser never has to reach
* the UGC server itself.
*/
(function () {
'use strict';
var STATES = { pending: ['Waiting', 'secondary'], done: ['Made', 'success'], failed: ['Failed', 'danger'], empty: ['Empty', 'light'] };
var SORTS = {
model: [['newest', 'Newest'], ['oldest', 'Oldest'], ['bricks', 'Most bricks'], ['triangles', 'Most triangles'], ['slowest', 'Slowest to make'], ['owner', 'Owner'], ['name', 'File name']],
modular: [['newest', 'Newest'], ['oldest', 'Oldest'], ['references', 'Most builds'], ['name', 'Name']]
};
// The list's state, as in the address: ?kind=&q=&state=&type=&sort=&page= (from 1)&view=, and the open item (&item=, &build=)
var list = { kind: 'model', q: '', state: '', type: '', sort: 'newest', page: 0, view: 'gallery' };
var canManage = false, publicUrl = '', items = [], total = 0, kinds = [];
var nifViewer = null, lxfmlViewer = null, current = null;
function $(id) { return document.getElementById(id); }
function fileUrl(itemKind, id, name) { return '/api/ugc/files/' + itemKind + '/' + encodeURIComponent(id) + '/' + name; }
function mb(bytes) { return (bytes / 1048576).toFixed(0) + ' MB'; }
function pageSize() { return +Prefs.get('ugc.pageSize.' + list.view, list.view === 'gallery' ? 48 : 50) || 48; }
// ---- the address ----
function readUrl() {
var p = new URLSearchParams(location.search);
list.kind = p.get('kind') === 'modular' ? 'modular' : 'model';
list.q = p.get('q') || '';
list.state = p.get('state') || '';
list.type = p.get('type') || '';
list.sort = p.get('sort') || 'newest';
list.page = Math.max(0, (parseInt(p.get('page'), 10) || 1) - 1);
var v = p.get('view');
if (v === 'list' || v === 'gallery') list.view = v;
else try { list.view = localStorage.getItem('ugcView') === 'list' ? 'list' : 'gallery'; } catch (e) { /* storage blocked */ }
var item = p.get('item');
return item && /^[\d-]{1,200}$/.test(item) ? { id: item, kind: list.kind, build: p.get('build') || '' } : null;
}
function writeUrl(push, item) {
var p = new URLSearchParams();
p.set('kind', list.kind);
if (list.q) p.set('q', list.q);
if (list.state) p.set('state', list.state);
if (list.type && list.kind === 'modular') p.set('type', list.type);
if (list.sort !== 'newest') p.set('sort', list.sort);
if (list.page) p.set('page', list.page + 1);
p.set('view', list.view);
if (item) {
p.set('item', item.id);
if (item.build) p.set('build', item.build);
}
var url = location.pathname + '?' + p.toString();
if (url === location.pathname + location.search) return;
if (push) history.pushState(null, '', url);
else history.replaceState(null, '', url);
}
function showControls() {
$('kindButtons').querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b.dataset.kind === list.kind); });
$('viewButtons').querySelectorAll('[data-view]').forEach(function (b) { b.classList.toggle('active', b.dataset.view === list.view); });
if (document.activeElement !== $('search')) $('search').value = list.q;
$('stateFilter').value = list.state;
$('typeFilter').classList.toggle('d-none', list.kind !== 'modular');
$('typeFilter').value = list.type;
$('sortSelect').innerHTML = SORTS[list.kind].map(function (o) { return ''; }).join('');
$('sortSelect').value = SORTS[list.kind].some(function (o) { return o[0] === list.sort; }) ? list.sort : 'newest';
$('sortSelect').classList.toggle('d-none', list.view !== 'gallery'); // the List view sorts by its column headings
$('pageSize').value = String(pageSize());
}
// ---- the list ----
function countCard(title, c) {
return '
';
}
function owner(i) {
return i.characterName ? '' + esc(i.characterName) + '' : '' + esc(i.characterId) + '';
}
function badge(state) { var s = STATES[state] || [state, 'secondary']; return fmt.badge(s[0], s[1]); }
// The id whose files show an item's icon: a model's own, an assembly's made build's (the UGC server finds its combination)
function iconId(i) { return list.kind === 'modular' ? i.iconBuild : i.id; }
function iconImg(i, size) {
if (i.state !== 'done') return '' + esc((STATES[i.state] || [i.state])[0]) + '';
return '';
}
function moduleNames(i) { return (i.moduleList || []).map(function (m) { return m.name || m.lot; }).join(', '); }
// How long a make took: "850 ms", "12.4 s", "2 min 5 s"
function duration(ms) {
if (ms < 1000) return ms + ' ms';
if (ms < 60000) return (ms / 1000).toFixed(1) + ' s';
return Math.floor(ms / 60000) + ' min ' + Math.round((ms % 60000) / 1000) + ' s';
}
// When it was made and how long that took (the time is only known for makes since it was recorded)
function madeText(i) {
if (!i.processedAt) return '';
return fmt.unix(i.processedAt) + (i.processMs ? ' \u00b7 took ' + duration(i.processMs) : '');
}
function waitBadge(i) {
return i.state === 'pending' && i.processAfter > Date.now() / 1000 ? ' after ' + esc(fmt.unix(i.processAfter)) + '' : '';
}
function tile(i) {
var title = list.kind === 'modular' ? esc(i.kindLabel || 'Build type ' + i.buildType) : (i.characterName ? esc(i.characterName) : '' + esc(i.characterId) + '');
var sub = list.kind === 'modular' ? esc(moduleNames(i)) : esc(i.id);
var extra = list.kind === 'modular' ? ' ' + fmt.badge(i.uses + ' build' + (i.uses === 1 ? '' : 's'), 'info') : waitBadge(i);
var tip = i.error || (list.kind === 'modular' ? moduleNames(i) : i.id) + (madeText(i) ? '\nMade ' + madeText(i) : '');
return '
' + errorText(i); }, 'name'),
column('State', function (i) { return badge(i.state); }),
column('Builds', function (i) { return esc(i.uses); }, 'references', true),
column('Owners', function (i) { return esc(i.owners); }),
column('', function (i) { return '
' + actions(i, i.iconBuild) + '
'; })
]
};
// The sort a DataTables order asks for, as /api/ugc's sort= and reverse=
function orderQuery(columns, order) {
var o = order && order[0], c = o && columns[o.column];
if (!c || !c.sortKey) return 'sort=newest';
return 'sort=' + c.sortKey + ((o.dir === 'desc') !== c.desc ? '&reverse=1' : '');
}
// Numbered pages with first and last, around the current one
function pager(el, page, pages, onPage) {
if (pages <= 1) { el.innerHTML = ''; return; }
var shown = [];
for (var p = Math.max(0, page - 2); p <= Math.min(pages - 1, page + 2); p++) shown.push(p);
var li = function (p, label, disabled, active) {
return '
' : '');
}).catch(function () { box.innerHTML = fmt.badge('Unknown', 'secondary') + ' Couldn\'t ask the dashboard for the UGC server\'s status.'; });
}
function remake(body, question) {
if (question && !confirm(question)) return;
body.kind = list.kind;
api.post('/api/ugc/reprocess', body).then(function (d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
toast(d.message, 'success');
load();
});
}
// ---- the open item ----
function statsTable(now, before) {
if (!now) return '
No stats for this model (made before the UGC server wrote them).
';
var lods = now.lods || [], old = before && before.lods || [];
var diff = function (a, b) {
if (b === undefined || b === null || a === b) return '';
var d = a - b;
return ' (' + (d > 0 ? '+' : '') + d + ')';
};
var rows = lods.map(function (l, i) {
var o = old[i] || {};
var removed = l.opaqueBefore ? (100 - l.opaqueAfter / l.opaqueBefore * 100).toFixed(0) + '%' : '';
return '
Modules (combination ' + esc(item.key) + ', used by ' + esc(item.uses) + ' build' + (item.uses === 1 ? '' : 's') +
'). The game client puts cars and rockets together itself; the UGC server only draws the icon, once per combination.
' + modulesHtml(item) : '';
$('referencesCard').classList.toggle('d-none', !modular);
if (modular) {
refs = { key: item.key, page: 0, q: '', highlight: build || '' };
$('refSearch').value = '';
loadRefs();
}
UgcIconEditor.open(modular ? { model: false, modules: item.modules || item.key.replace(/-/g, '+'), label: item.key } : { model: true, id: id });
bootstrap.Modal.getOrCreateInstance($('previewModal')).show();
if (modular) return;
Promise.all([fetchJson(fileUrl('model', id, 'stats.json')), fetchJson(fileUrl('model', id, 'previous.stats.json'))]).then(function (s) {
if (!current || current.id !== id) return;
current.stats = s[0];
current.statsLoaded = true;
$('statsBox').innerHTML = statsTable(s[0], s[1]);
var lods = (s[0] && s[0].lods) || [{ lod: 0 }];
$('lodSelect').innerHTML = lods.map(function (l, n) { return ''; }).join('');
$('versionSelect').querySelector('[value=previous]').disabled = !s[1];
$('versionSelect').value = 'current';
loadMesh(true);
});
var nifBox = $('nifViewer'), lxfmlBox = $('lxfmlViewer');
import('/js/ugc-viewer.js').then(function (module) {
if (!nifViewer) {
nifViewer = module.createNifViewer(nifBox);
nifViewer.setWireframe($('wireframeSwitch').checked);
nifViewer.setVertexColors($('colorsSwitch').checked);
}
if (current && current.id === id && current.statsLoaded) loadMesh(true);
}).catch(function (e) { nifBox.textContent = 'The 3D view could not load: ' + e.message; });
lxfmlBox.textContent = 'Loading…';
import('/js/lddviewer.js').then(function (module) {
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlBox.textContent = '';
lxfmlViewer = module.createViewer(lxfmlBox, {});
return lxfmlViewer.load([{ id: id, name: 'Model ' + id, position: [0, 0, 0], rotation: [0, 0, 0, 1], url: '/api/ugc/' + id + '/lxfml' }], 1);
}).catch(function (e) { lxfmlBox.textContent = 'The 3D view could not load: ' + e.message; });
}
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
function readUrlItem() {
var p = new URLSearchParams(location.search), item = p.get('item');
return item ? { id: item, build: p.get('build') || '' } : null;
}
// ---- an assembly's references: the builds that use it, and where they are ----
var refs = null, refTimer = null;
function whereText(w) {
return (w || []).map(function (x) {
if (x.type === 'property') return 'On ' + esc(x.propertyName || 'property ' + x.propertyId) + '' + (x.ownerName ? ' of ' + esc(x.ownerName) : '');
if (x.type === 'mail') return 'In a mail to ' + esc(x.characterName || x.characterId) + '';
return 'In the creator\'s ' + esc(x.inventory || 'inventory');
}).join(' ') || 'Not found placed, mailed or with its creator';
}
// The builds that use the open assembly: a server-side DataTable, made once and reloaded for each assembly. A
// linked build (refs.highlight) opens on the page holding it, marked.
var REF_COLUMNS = [
column('Build (blueprint id)', function (b) { return '' + esc(b.id) + ''; }, 'id', true),
column('Owner', owner, 'owner'),
column('Account', function (b) { return b.accountId ? '' + esc(b.accountName || b.accountId) + '' : ''; }, 'account'),
column('State', function (b) { return badge(b.state) + errorText(b); }, 'state'),
column('Made', function (b) { return '' + esc(madeText(b)) + ''; }),
column('Where it is', function (b) { return whereText(b.where); }),
column('', function (b) { return '