Files
DarkflameServer/dDashboardServer/static/js/ugc.js
Aaron Kimbrell 063361711b feat(dashboard): pick and compare the processor
Make again gets a Made by picker, /api/ugc/options lists the processor
choices and default, the settings page has the toolbox_* settings, and
/reprocessproperty takes native or toolbox-blender.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:47:40 -05:00

763 lines
49 KiB
JavaScript

/**
* 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'], ['made', 'Recently made'], ['slowest', 'Slowest to make'], ['cpu', 'Most CPU'], ['memory', 'Most RAM'], ['savings', 'Most triangles saved'], ['owner', 'Owner'], ['name', 'File name']],
modular: [['newest', 'Newest'], ['oldest', 'Oldest'], ['references', 'Most builds'], ['modules', 'Most modules'], ['made', 'Recently made'], ['slowest', 'Slowest to make'], ['cpu', 'Most CPU'], ['memory', 'Most RAM'], ['owner', 'Owner'], ['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 '<option value="' + o[0] + '">' + esc(o[1]) + '</option>'; }).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 '<div class="col-md-6"><div class="card"><div class="card-body py-2"><div class="small text-body-secondary">' + esc(title) + '</div>' +
fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') +
(c.empty ? ' ' + fmt.badge(c.empty + ' empty', 'light') : '') + '</div></div></div>';
}
// What making them all took, and what it saved (the rows' recorded stats; ones made before a stat existed don't count)
function totalsCard(title, t) {
if (!t || !t.made) return '';
var saved = t.trianglesBefore ? t.trianglesBefore - t.trianglesAfter : 0;
var rows = [
['Time spent making', duration(t.ms) + (t.timed < t.made ? ' <span class="text-body-secondary">(' + esc(t.timed) + ' of ' + esc(t.made) + ' timed)</span>' : '')],
['CPU time', duration(t.cpuMs)],
['Average / slowest', duration(t.averageMs) + ' / ' + duration(t.maxMs)],
['Memory (est.) average / most', mb(t.memoryKbAverage * 1024) + ' / ' + mb(t.memoryKbMax * 1024)]
];
if (t.bricks) rows.push(['Bricks', Number(t.bricks).toLocaleString()]);
if (t.triangles) rows.push(['Triangles (most detailed level)', Number(t.triangles).toLocaleString()]);
if (saved) rows.push(['Triangles saved', Number(saved).toLocaleString() + ' (' + (saved / t.trianglesBefore * 100).toFixed(1) + '% of ' + Number(t.trianglesBefore).toLocaleString() + ')']);
return '<div class="col-md-6"><div class="card"><div class="card-body py-2"><div class="small text-body-secondary mb-1">' + esc(title) + ': totals</div>' +
'<table class="table table-sm mb-0 small"><tbody>' + rows.map(function (r) { return '<tr><th class="fw-normal text-body-secondary">' + r[0] + '</th><td>' + r[1] + '</td></tr>'; }).join('') +
'</tbody></table></div></div></div>';
}
// The creator's character, and account for those who may see accounts
function owner(i) {
var character = i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
var account = i.accountId && i.accountName ? '<div class="small text-body-secondary">' + (DASH.can('accounts_view') ? fmt.link('/accounts/' + i.accountId, i.accountName) : esc(i.accountName)) + '</div>' : '';
return character + account;
}
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 '<span class="ugc-noicon text-body-secondary small border rounded" style="width:' + size + 'px;height:' + size + 'px">' + esc((STATES[i.state] || [i.state])[0]) + '</span>';
return '<img src="' + esc(fileUrl(list.kind, iconId(i), 'icon.png')) + '" width="' + size + '" height="' + size + '" loading="lazy" alt="" class="ugc-checker rounded" onerror="this.style.visibility=\'hidden\'">';
}
function moduleNames(i) { return (i.moduleList || []).map(function (m) { return m.name || m.lot; }).join(', '); }
// A duration in the largest units that fit: "850 ms", "12.4 s", "2 min 5 s", "1 h 4 min", "2 d 3 h"
function duration(ms) {
ms = Math.round(ms);
if (ms < 1000) return ms + ' ms';
if (ms < 60000) return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + ' s';
var s = Math.round(ms / 1000), m = Math.floor(s / 60), h = Math.floor(m / 60), d = Math.floor(h / 24);
if (d) return d + ' d' + (h % 24 ? ' ' + (h % 24) + ' h' : '');
if (h) return h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '');
return m + ' min' + (s % 60 ? ' ' + (s % 60) + ' s' : '');
}
function megabytes(kb) { return kb >= 1048576 ? (kb / 1048576).toFixed(1) + ' GB' : kb >= 1024 ? Math.round(kb / 1024) + ' MB' : kb + ' KB'; }
// What the last make cost: its time, the worker's CPU time and the memory the UGC server estimated for it
function costText(i) {
if (!i.processMs) return '';
return 'took ' + duration(i.processMs) + (i.processCpuMs ? ', CPU ' + duration(i.processCpuMs) : '') + (i.processMemoryKb ? ', ~' + megabytes(i.processMemoryKb) + ' RAM (est.)' : '');
}
// When it was made and what that cost (the cost is only known for makes since it was recorded), and with what
function madeText(i) {
if (!i.processedAt) return '';
return fmt.unix(i.processedAt) + (costText(i) ? ' \u00b7 ' + costText(i) : '') + (i.madeOptions ? ' \u00b7 with ' + i.madeOptions : '');
}
// The processing options picked for Make again (ray tracer, denoise, processor), as /api/ugc/reprocess takes them
function remakeOptions() {
return ['optRays', 'optDenoise', 'optProcessor'].map(function (x) { return $(x).value; }).filter(Boolean).join(' ');
}
// A model's file as the player named it: its name with the upload's extension (".lxfml"), else the upload's name
function fileName(i) {
var file = i.detail || '';
if (!i.modelName) return file;
var dot = file.lastIndexOf('.');
return i.modelName + (dot > 0 ? file.slice(dot) : '');
}
function waitBadge(i) {
return i.state === 'pending' && i.processAfter > Date.now() / 1000 ? ' <span class="small text-body-secondary" title="Waits for the owner to stop saving">after ' + esc(fmt.unix(i.processAfter)) + '</span>' : '';
}
function tile(i) {
var title = list.kind === 'modular' ? esc(i.kindLabel || 'Build type ' + i.buildType) : (i.characterName ? esc(i.characterName) : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>');
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 '<div class="card ugc-tile p-2" tabindex="0" role="button" data-preview="' + esc(i.id) + '" title="' + esc(tip) + '">' + iconImg(i, 128) +
'<div class="small text-truncate mt-1">' + title + '</div><div class="small text-body-secondary text-truncate">' + sub + '</div><div>' + badge(i.state) + extra + '</div></div>';
}
function actions(i, remakeId) {
return '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>' +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(remakeId) + '">Make again</button>' : '');
}
function errorText(i) { return i.error ? '<div class="small text-danger">' + esc(i.error) + '</div>' : ''; }
// The List view's columns. A column with a sort key sorts on the server by that key; desc marks the key's own
// direction (e.g. "bricks" is the most first), so clicking the other way asks for the reverse.
function column(title, render, sort, desc) {
return { data: null, title: title, orderable: !!sort, sortKey: sort, desc: !!desc, orderSequence: desc ? ['desc', 'asc'] : ['asc', 'desc'],
render: function (x, type, i) { return render(i); } };
}
// When it was made and what that cost: the same columns for models and assemblies
function madeColumns() {
return [
column('Made', function (i) { return '<span class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</span>'; }, 'made', true),
column('Took', function (i) { return '<span class="small">' + (i.processMs ? esc(duration(i.processMs)) : '') + '</span>'; }, 'slowest', true),
column('CPU', function (i) { return '<span class="small">' + (i.processCpuMs ? esc(duration(i.processCpuMs)) : '') + '</span>'; }, 'cpu', true),
column('RAM (est.)', function (i) { return '<span class="small">' + (i.processMemoryKb ? '~' + esc(megabytes(i.processMemoryKb)) : '') + '</span>'; }, 'memory', true)
];
}
var COLUMNS = {
model: [
column('Icon', function (i) { return iconImg(i, 48); }),
column('ID', function (i) { return '<span class="small">' + esc(i.id) + '</span>'; }, 'newest', true),
column('Owner', owner, 'owner'),
column('State', function (i) {
return badge(i.state) + (i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + waitBadge(i);
})
].concat(madeColumns(), [
column('Options', function (i) {
return '<span class="small">' + esc(i.madeOptions || '') + '</span>' +
(i.processOptions ? '<div class="small text-body-secondary" title="Picked for its next make">next: ' + esc(i.processOptions) + '</div>' : '');
}),
column('Saved', function (i) {
if (!i.trianglesBefore) return '';
var removed = i.trianglesBefore - i.triangles, share = removed / i.trianglesBefore * 100;
return '<span class="small" title="' + esc(i.trianglesBefore.toLocaleString()) + ' triangles before, ' + esc(i.triangles.toLocaleString()) + ' after">' +
esc(share.toFixed(share < 10 ? 1 : 0)) + '%<br><span class="text-body-secondary">' + esc(removed.toLocaleString()) + ' tris</span></span>';
}, 'savings', true),
column('Size', function (i) { return '<span class="small">' + (i.bricks ? esc(i.bricks) + ' bricks<br>' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + '</span>'; }, 'bricks', true),
column('File', function (i) { return '<span class="small" title="' + esc(i.detail || '') + '">' + esc(fileName(i)) + '</span>' + errorText(i); }, 'name'),
column('Where', function (i) { return '<span class="small">' + whereText(i.where, i) + '</span>'; }),
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.id) + '</div>'; })
]),
// The models' columns but Saved: an assembly's ID and owner are its newest build's, its size its modules, its
// file its type and modules, and it is where the builds that use it are (References)
modular: [
column('Icon', function (i) { return iconImg(i, 48); }),
column('ID', function (i) { return '<span class="small" title="The newest build">' + esc(i.newestBuild) + '</span>'; }, 'newest', true),
column('Owner', function (i) {
return owner(i) + (i.owners > 1 ? '<div class="small text-body-secondary">and ' + esc(i.owners - 1) + ' other' + (i.owners === 2 ? '' : 's') + '</div>' : '');
}, 'owner'),
column('State', function (i) { return badge(i.state); })
].concat(madeColumns(), [
column('Size', function (i) { return '<span class="small">' + esc((i.moduleList || []).length) + ' modules</span>'; }, 'modules', true),
column('File', function (i) {
return '<span class="small">' + esc(i.kindLabel || i.buildType) + ': ' + esc(moduleNames(i)) + '</span><div><code class="small">' + esc(i.key) + '</code></div>' + errorText(i);
}, 'name'),
column('Where', function (i) {
return '<span class="small">Used by ' + esc(i.uses) + ' build' + (i.uses === 1 ? '' : 's') + ' of ' + esc(i.owners) + ' owner' + (i.owners === 1 ? '' : 's') +
'</span><div><button type="button" class="btn btn-link btn-sm p-0" data-preview="' + esc(i.id) + '">References</button></div>';
}, 'references', true),
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.iconBuild) + '</div>'; })
])
};
// 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 '<li class="page-item' + (disabled ? ' disabled' : '') + (active ? ' active' : '') + '"><a class="page-link" href="#" data-page="' + p + '"' +
(active ? ' aria-current="page"' : '') + '>' + label + '</a></li>';
};
var html = li(0, '&laquo;', page === 0) + li(Math.max(0, page - 1), '&lsaquo;', page === 0);
if (shown[0] > 0) html += li(0, '1') + (shown[0] > 1 ? '<li class="page-item disabled"><span class="page-link">&hellip;</span></li>' : '');
shown.forEach(function (p) { html += li(p, p + 1, false, p === page); });
if (shown[shown.length - 1] < pages - 1) html += (shown[shown.length - 1] < pages - 2 ? '<li class="page-item disabled"><span class="page-link">&hellip;</span></li>' : '') + li(pages - 1, pages);
html += li(Math.min(pages - 1, page + 1), '&rsaquo;', page >= pages - 1) + li(pages - 1, '&raquo;', page >= pages - 1);
el.innerHTML = html;
el.onclick = function (e) {
var a = e.target.closest('[data-page]');
if (!a || a.parentElement.classList.contains('disabled')) return;
e.preventDefault();
onPage(+a.dataset.page);
};
}
// What every /api/ugc answer carries besides the items
function applyMeta(d) {
canManage = d.canManage;
publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, '');
if (d.kinds && !kinds.length) {
kinds = d.kinds;
$('typeFilter').innerHTML = '<option value="">Every type</option>' + kinds.filter(function (k) { return k.buildType !== undefined; })
.map(function (k) { return '<option value="' + esc(k.kind) + '">' + esc(k.label) + '</option>'; }).join('');
$('typeFilter').value = list.type;
}
// While searching, how many of each kind match (the search looks in both)
$('kindButtons').querySelectorAll('[data-kind]').forEach(function (b) {
var count = d.matches ? d.matches[b.dataset.kind] : null;
b.textContent = (b.dataset.kind === 'modular' ? 'Cars and rockets' : 'Models') + (count === null || count === undefined ? '' : ' (' + Number(count).toLocaleString() + ')');
});
$('manageButtons').classList.toggle('d-none', !canManage);
$('remakeOptions').classList.toggle('d-none', list.kind !== 'model');
$('cacheCard').classList.toggle('d-none', !canManage);
$('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular) +
(d.totals ? totalsCard('Models', d.totals.model) + totalsCard('Cars and rockets', d.totals.modular) : '');
}
function filterQuery() {
return 'kind=' + list.kind + '&q=' + encodeURIComponent(list.q) + '&state=' + list.state + '&type=' + encodeURIComponent(list.kind === 'modular' ? list.type : '');
}
// The List view: a server-side DataTable per kind, made the first time it shows. Search and filters stay the
// page's own (above); DataTables does the paging, and sorting by the column headings.
var tables = {}, tableLoaded = null;
function listTable(kind) {
if (tables[kind]) return tables[kind];
var columns = COLUMNS[kind];
tables[kind] = serverTable('#' + (kind === 'modular' ? 'ugcAssemblyTable' : 'ugcModelTable'), null, columns, { dataTable: {
order: [[1, 'desc']],
pageLength: 50,
autoWidth: false,
layout: { topStart: 'pageLength', topEnd: 'info', bottomStart: null, bottomEnd: 'paging' },
language: { emptyTable: 'Nothing here.', zeroRecords: 'Nothing matches.' },
ajax: function (data, callback) {
var size = data.length > 0 ? data.length : 200;
api.get('/api/ugc?' + filterQuery() + '&' + orderQuery(columns, data.order) + '&page=' + Math.floor(data.start / size) + '&size=' + size + (kind === 'model' ? '&where=1' : '')).then(function (d) {
if (!d.success) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [], error: d.error || 'Failed' }); return; }
applyMeta(d);
if (list.kind === kind) items = d.items;
callback({ draw: data.draw, recordsTotal: d.total, recordsFiltered: d.total, data: d.items });
if (tableLoaded) { tableLoaded(); tableLoaded = null; }
}).catch(function () { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); });
}
} });
return tables[kind];
}
var loadSequence = 0;
function load() {
showControls();
var gallery = list.view === 'gallery';
$('gallery').classList.toggle('d-none', !gallery);
$('pagerBar').classList.toggle('d-none', !gallery);
$('listCard').classList.toggle('d-none', gallery);
$('modelTableBox').classList.toggle('d-none', list.kind !== 'model');
$('assemblyTableBox').classList.toggle('d-none', list.kind !== 'modular');
if (!gallery) {
return new Promise(function (resolve) {
tableLoaded = resolve;
var fresh = !tables[list.kind];
var table = listTable(list.kind);
if (!fresh) table.ajax.reload(null, true);
});
}
var size = pageSize(), sequence = ++loadSequence;
return api.get('/api/ugc?' + filterQuery() + '&sort=' + list.sort + '&page=' + list.page + '&size=' + size).then(function (d) {
if (!d.success || sequence !== loadSequence) return;
items = d.items;
total = d.total;
applyMeta(d);
$('gallery').innerHTML = items.map(tile).join('') || '<div class="text-body-secondary">' + (list.q || list.state || list.type ? 'Nothing matches.' : 'Nothing here.') + '</div>';
var pages = Math.max(1, Math.ceil(total / size));
pager($('pager'), list.page, pages, function (p) { list.page = p; writeUrl(true); load(); });
$('jumpPage').max = pages;
$('jumpPage').placeholder = (list.page + 1) + ' / ' + pages;
$('totalText').textContent = total.toLocaleString() + (list.kind === 'modular' ? ' assembl' + (total === 1 ? 'y' : 'ies') : ' model' + (total === 1 ? '' : 's'));
}).catch(function () {});
}
function changeList(changes, push) {
for (var k in changes) list[k] = changes[k];
writeUrl(push);
load();
}
function meter(label, value, limit, text) {
var share = limit > 0 ? Math.min(100, value / limit * 100) : 0;
var colour = share > 90 ? 'danger' : share > 70 ? 'warning' : 'success';
return '<div class="col-md-4"><div class="d-flex justify-content-between"><span>' + esc(label) + '</span><span class="text-body-secondary">' + text + '</span></div>' +
(limit > 0 ? '<div class="progress ugc-meter" role="progressbar" aria-label="' + esc(label) + '" aria-valuenow="' + Math.round(share) + '" aria-valuemin="0" aria-valuemax="100">' +
'<div class="progress-bar bg-' + colour + '" style="width:' + share.toFixed(0) + '%"></div></div>' : '') + '</div>';
}
function loadStatus() {
var box = $('serverStatus');
api.get('/api/ugc/server/status').then(function (d) {
if (!d.success) {
box.innerHTML = fmt.badge('Unreachable', 'warning') + ' The dashboard can\'t reach the UGC server at ' + esc(d.url || '') + (d.error ? ' (' + esc(d.error) + ')' : '') +
'. Is <code>enable_ugc_server</code> on, and <code>ugc_internal_url</code> (dashboard settings) right?';
return;
}
var s = d.status, u = s.usage || {}, l = s.limits || {}, last = s.recent && s.recent[0];
var cores = u.cores || 1, machinePercent = (u.cpuPercent || 0) / cores;
var badges = fmt.badge('Online', 'success') + (u.throttled ? ' ' + fmt.badge('Throttled', 'warning') : '') + (u.paused ? ' ' + fmt.badge('Paused (' + esc(l.pauseHours) + ')', 'info') : '');
box.innerHTML = '<div class="mb-2">' + badges + ' ' + esc(s.workers) + ' workers, ' + esc(s.active) + ' working, ' + esc(s.queued) + ' queued. ' +
'Since it started: ' + esc(s.made) + ' made, ' + esc(s.failed) + ' failed attempts. Files: ' + esc(mb(s.storedBytes)) +
(s.maxStorageBytes ? ' of ' + esc(mb(s.maxStorageBytes)) : '') + (s.evicted ? ', ' + esc(s.evicted) + ' deleted to save space' : '') + '.</div>' +
'<div class="row g-3 mb-1">' +
meter('CPU', machinePercent, l.maxCpus ? l.maxCpus / cores * 100 : 0, machinePercent.toFixed(0) + '% of ' + cores + ' cores' +
(l.maxCpus ? ' (limit ' + (l.maxCpus / cores * 100).toFixed(0) + '%)' : ', no limit') + (u.throttledMs ? ', paused ' + (u.throttledMs / 1000).toFixed(0) + ' s to stay under it' : '')) +
meter('Memory', u.residentBytes || 0, 0, esc(mb(u.residentBytes || 0)) + ' in use') +
meter('Jobs\' memory', s.jobMemoryBytes || 0, l.maxMemoryBytes || 0, esc(mb(s.jobMemoryBytes || 0)) + ' estimated' +
(l.maxMemoryBytes ? ' of ' + esc(mb(l.maxMemoryBytes)) : ', no limit') + (s.memoryWaits ? ', ' + esc(s.memoryWaits) + ' waits' : '')) +
'</div>' + (l.nice ? '<div class="text-body-secondary">Workers run at priority ' + esc(l.nice) + '.</div>' : '') +
purgeLine(s.purge) +
(last ? '<div class="text-body-secondary">Last: ' + esc(last.kind) + ' ' + esc(last.id) + (last.ok ? ' made in ' + esc(duration(last.ms)) : ' failed') +
(last.message ? ' (' + esc(last.message) + ')' : '') + '</div>' : '');
}).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;
if (list.kind === 'model' && remakeOptions()) body.options = remakeOptions();
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 '<div class="small text-body-secondary">No stats for this model (made before the UGC server wrote them).</div>';
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 ' <span class="' + (d < 0 ? 'text-success' : 'text-warning') + '">(' + (d > 0 ? '+' : '') + d + ')</span>';
};
var rows = lods.map(function (l, i) {
var o = old[i] || {};
var removed = l.opaqueBefore ? (100 - l.opaqueAfter / l.opaqueBefore * 100).toFixed(0) + '%' : '';
return '<tr><td>LOD ' + esc(l.lod) + '</td><td>' + esc(l.near) + ' - ' + esc(l.far) + '</td><td>' + esc(l.opaqueBefore) + '</td><td>' + esc(l.opaqueAfter) + diff(l.opaqueAfter, o.opaqueAfter) +
'</td><td>' + esc(removed) + '</td><td>' + esc(l.transparent) + '</td><td>' + esc(l.vertices) + diff(l.vertices, o.vertices) + '</td><td>' + esc(l.shapes) + '</td></tr>';
}).join('');
var ms = now.ms || {}, set = now.settings || {};
return '<div class="table-responsive"><table class="table table-sm small mb-1"><thead><tr><th>Detail</th><th>Drawn from - to</th><th>Opaque triangles</th>' +
'<th>After removing hidden faces</th><th>Removed</th><th>Transparent triangles</th><th>Vertices</th><th>Shapes</th></tr></thead><tbody>' + rows + '</tbody></table></div>' +
'<div class="small text-body-secondary">' + esc(now.bricks) + ' bricks' + (now.missingDesigns ? ', no geometry for ' + esc(now.missingDesigns.join(', ')) : '') +
'. Took ' + esc(ms.total) + ' ms (build ' + esc(ms.build) + ', hidden faces ' + esc(ms.hiddenSurfaces) + ', occlusion ' + esc(ms.ambientOcclusion) + ', icon ' + esc(ms.icon) + ')' +
(before && before.ms ? ', before ' + esc(before.ms.total) + ' ms' : '') + '. Colors: ' + esc(set.palette) + ', variation ' + esc(set.colorVariation) + '%' +
(set.ao ? ', occlusion ' + esc(set.aoSamples) + ' rays to ' + esc(set.aoDistance) : ', no occlusion') + '.</div>';
}
function loadMesh(reframe) {
if (!current || !nifViewer) return;
var lod = $('lodSelect').value || '0', version = $('versionSelect').value, ao = $('aoSwitch').checked ? '1' : '0';
var stats = $('nifStats');
stats.textContent = 'Loading…';
nifViewer.load('/api/ugc/mesh/' + encodeURIComponent(current.id) + '?lod=' + lod + '&version=' + version + '&ao=' + ao, reframe).then(function (r) {
var lodStats = current.stats && current.stats.lods && current.stats.lods[+lod];
stats.textContent = r.triangles.toLocaleString() + ' triangles, ' + r.vertices.toLocaleString() + ' vertices in ' + r.shapes + ' shape' + (r.shapes === 1 ? '' : 's') +
(lodStats && version === 'current' ? ' (' + lodStats.opaqueBefore.toLocaleString() + ' opaque triangles before hidden faces were removed)' : '');
}).catch(function (e) { stats.textContent = 'Could not load the mesh: ' + e.message; });
}
function openItem(itemKind, id, build) {
if (list.kind !== itemKind) changeList({ kind: itemKind, page: 0, sort: 'newest', type: '' }, true);
var known = items.find(function (i) { return i.id === id; });
if (known) return preview(known, build);
if (itemKind === 'modular' && /^\d+$/.test(id) && id.length > 12) {
// A build's id (links from elsewhere): open the assembly it uses, with the build shown in References
return api.get('/api/ugc/assembly/of/' + id).then(function (d) {
if (!d.success) { toast(d.error || 'No such build', 'warning'); return; }
findAndOpen('modular', d.key, id);
});
}
findAndOpen(itemKind, id, build);
}
// Not on the page showing: looked up by its id (a model's) or its modules (an assembly's)
function findAndOpen(itemKind, id, build) {
var q = itemKind === 'modular' ? 'lot:' + id.split('-')[0] : 'id:' + id;
api.get('/api/ugc?kind=' + itemKind + '&q=' + encodeURIComponent(q) + '&size=200').then(function (d) {
canManage = d.canManage;
var found = (d.items || []).find(function (i) { return i.id === id; });
preview(found || (itemKind === 'modular' ? { id: id, key: id, modules: id.replace(/-/g, '+'), moduleList: [], state: 'pending', uses: 0 } : { id: id, state: 'done' }), build);
}).catch(function () {});
}
function modulesHtml(i) {
return '<div class="d-flex flex-wrap gap-2 ugc-modules">' + (i.moduleList || []).map(function (m) {
return '<div class="d-flex align-items-center gap-1 border rounded px-1"><img src="' + esc(m.icon) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">' +
'<div><div>' + esc(m.name || 'LOT ' + m.lot) + '</div><div class="text-body-secondary">' + (DASH.can('dev_cdclient') ? '<a href="/cdclient#/object/' + esc(m.lot) + '">LOT ' + esc(m.lot) + '</a>' : 'LOT ' + esc(m.lot)) + '</div></div></div>';
}).join('') + '</div>';
}
function preview(item, build) {
var modular = list.kind === 'modular', id = item.id;
current = { id: id, kind: list.kind, stats: null, fileId: modular ? item.iconBuild : id, item: item, build: build || '' };
var shown = readUrlItem();
if (!shown || shown.id !== id) writeUrl(true, { id: id, build: build });
$('previewTitle').textContent = modular ? (item.kindLabel || 'Car or rocket') + ': ' + (moduleNames(item) || item.key) : 'Model ' + id + (item.characterName ? ' by ' + item.characterName : '');
$('previewIcon').src = modular && !item.iconBuild ? '' : fileUrl(list.kind, current.fileId, 'icon.png');
$('previousIconBox').classList.add('d-none');
var previous = $('previousIcon');
previous.onload = function () { $('previousIconBox').classList.remove('d-none'); };
previous.src = modular && !item.iconBuild ? '' : fileUrl(list.kind, current.fileId, 'previous.icon.png');
var links = '';
if (!modular) {
links += '<a href="' + esc(fileUrl('model', id, 'model.nif')) + '" download="ugc_' + esc(id) + '.nif">Download the mesh (.nif)</a><br>' +
'<a href="/api/ugc/' + esc(id) + '/lxfml" download="ugc_' + esc(id) + '.lxfml">Download the LXFML</a><br>';
}
if (publicUrl && current.fileId) links += '<a href="' + esc(publicUrl + '/files/' + list.kind + '/' + current.fileId + '/icon.png') + '" target="_blank" rel="noopener">Open on the UGC server</a>';
if (canManage && current.fileId) {
links += '<div class="mt-2 d-flex flex-wrap gap-1"><button type="button" class="btn btn-sm btn-outline-warning" data-remake-open="' + esc(current.fileId) + '">Make again</button>' +
'<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="now">Delete files, make again</button>' +
'<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="gone">Delete files, leave deleted</button></div>';
}
if (item.error) links += '<div class="text-danger mt-2">' + esc(item.error) + '</div>';
$('previewLinks').innerHTML = (!modular && madeText(item) ? '<div class="small text-body-secondary mb-1">Made ' + esc(madeText(item)) + '</div>' : '') + links;
['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', modular); });
$('statsBox').innerHTML = modular ? '<div class="small mb-1">Modules (combination <code>' + esc(item.key) + '</code>, 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.</div>' + 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 '<option value="' + n + '">LOD ' + esc(l.lod) + (l.far ? ' (' + esc(l.near) + '-' + esc(l.far) + ')' : '') + '</option>'; }).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;
var INVENTORIES = { 5: 'Models', 12: 'Vault models', 14: 'Brick-building models' };
function inventoryName(type) { return (window.Labels && Labels.name('inventories', type)) || INVENTORIES[type] || 'Inventory ' + type; }
// Where a creation is: placed on a property (with the name the player gave it there, and its 3D view), in a mail or
// in its creator's inventories (`item`: the creation, for its creator's name)
function whereText(w, item) {
return (w || []).map(function (x) {
if (x.type === 'property') {
// A model the player didn't name has the client's placeholder (Objects_<lot>_name)
var named = x.modelName && !/^Objects_\d+_name$/.test(x.modelName) ? ' as <span class="text-break">' + esc(x.modelName) + '</span>' : '';
return '<div>On ' + fmt.link('/properties/' + x.propertyId, x.propertyName || 'property ' + x.propertyId) + named +
(x.ownerName ? ' of ' + fmt.character(x.ownerId, x.ownerName) : '') +
(x.modelId ? ' <a href="/properties/' + esc(x.propertyId) + '/3d#model=' + esc(x.modelId) + '">3D</a>' : '') + '</div>';
}
if (x.type === 'mail') return '<div>In a mail to ' + fmt.character(x.characterId, x.characterName) + '</div>';
return '<div>In ' + (item && item.characterName ? fmt.character(x.characterId, item.characterName) + '\'s ' : 'the creator\'s ') + esc(inventoryName(x.inventory)) + '</div>';
}).join('') || '<span class="text-body-secondary" title="Not placed on a property, not in the mail and not in its creator\'s inventories: traded, sold or deleted">Not found</span>';
}
// 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 '<code>' + esc(b.id) + '</code>'; }, 'id', true),
column('Owner', owner, 'owner'),
column('Account', function (b) { return b.accountId ? '<a href="/accounts/' + esc(b.accountId) + '">' + esc(b.accountName || b.accountId) + '</a>' : ''; }, 'account'),
column('State', function (b) { return badge(b.state) + errorText(b); }, 'state'),
column('Made', function (b) { return '<span class="small">' + esc(madeText(b)) + '</span>'; }),
column('Where it is', function (b) { return whereText(b.where, b); })
];
var refTable = null;
function loadRefs() {
if (!refs) return;
if (refTable) { refTable.ajax.reload(null, true); return; }
refTable = serverTable('#refTable', null, REF_COLUMNS, { dataTable: {
order: [[0, 'desc']],
pageLength: 25,
autoWidth: false,
layout: { topStart: 'pageLength', topEnd: 'info', bottomStart: null, bottomEnd: 'paging' },
language: { emptyTable: 'No builds use these modules.', zeroRecords: 'No builds match.' },
createdRow: function (tr, b) { if (refs && b.id === refs.highlight) tr.classList.add('ugc-highlight'); },
ajax: function (data, callback) {
if (!refs) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); return; }
var wanted = refs, size = data.length > 0 ? data.length : 200, highlight = refs.highlight && !refs.shown ? refs.highlight : '';
var sort = orderQuery(REF_COLUMNS, data.order).replace('sort=newest', 'sort=id');
api.get('/api/ugc/assembly/builds?modules=' + encodeURIComponent(refs.key) + '&q=' + encodeURIComponent(refs.q) + '&' + sort +
'&page=' + Math.floor(data.start / size) + '&size=' + size + (highlight ? '&build=' + encodeURIComponent(highlight) : '')).then(function (d) {
if (refs !== wanted) return;
if (!d.success) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [], error: d.error || 'Failed' }); return; }
refs.shown = true;
callback({ draw: data.draw, recordsTotal: d.total, recordsFiltered: d.total, data: d.items });
// The linked build is on another page: go there (the server said which)
if (highlight && d.page * size !== data.start) setTimeout(function () { refTable.page(d.page).draw('page'); }, 0);
var marked = document.querySelector('#refTable .ugc-highlight');
if (marked) marked.scrollIntoView({ block: 'nearest' });
}).catch(function () { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); });
}
} });
}
$('refSearch').addEventListener('input', function () {
var value = this.value.trim();
clearTimeout(refTimer);
refTimer = setTimeout(function () { if (refs) { refs.q = value; refs.highlight = ''; loadRefs(); } }, 300);
});
// ---- controls ----
$('kindButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-kind]');
if (button && button.dataset.kind !== list.kind) changeList({ kind: button.dataset.kind, page: 0, sort: 'newest', type: '' }, true);
});
$('viewButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-view]');
if (!button) return;
try { localStorage.setItem('ugcView', button.dataset.view); } catch (err) { /* storage blocked */ }
changeList({ view: button.dataset.view, page: 0 }, false);
});
$('stateFilter').addEventListener('change', function () { changeList({ state: this.value, page: 0 }, true); });
$('typeFilter').addEventListener('change', function () { changeList({ type: this.value, page: 0 }, true); });
$('sortSelect').addEventListener('change', function () { changeList({ sort: this.value, page: 0 }, true); });
$('pageSize').addEventListener('change', function () { Prefs.set('ugc.pageSize.' + list.view, +this.value); changeList({ page: 0 }, false); });
$('jumpPage').addEventListener('change', function () {
var p = Math.max(1, Math.min(+this.max || 1, parseInt(this.value, 10) || 1));
this.value = '';
changeList({ page: p - 1 }, true);
});
var searchTimer = null;
$('search').addEventListener('input', function () {
var value = this.value.trim();
clearTimeout(searchTimer);
searchTimer = setTimeout(function () { changeList({ q: value, page: 0 }, false); }, 300);
});
$('retryFailed').addEventListener('click', function () { remake({ failedOnly: true }); });
$('remakeAll').addEventListener('click', function () {
remake({}, 'Make every ' + (list.kind === 'model' ? 'model' : 'car and rocket') + ' again? This can take a long time.');
});
function onItemClick(e) {
var remakeButton = e.target.closest('[data-remake]'), previewButton = e.target.closest('[data-preview]');
if (remakeButton) remake({ id: remakeButton.dataset.remake });
else if (previewButton) {
var item = items.find(function (i) { return i.id === previewButton.dataset.preview; });
if (item) preview(item);
}
}
$('listCard').addEventListener('click', onItemClick);
$('gallery').addEventListener('click', onItemClick);
$('gallery').addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onItemClick(e); } });
$('previewLinks').addEventListener('click', function (e) {
var button = e.target.closest('[data-remake-open]');
if (button) remake({ id: button.dataset.remakeOpen });
});
$('lodSelect').addEventListener('change', function () { loadMesh(false); });
$('versionSelect').addEventListener('change', function () { loadMesh(false); });
$('aoSwitch').addEventListener('change', function () { loadMesh(false); });
$('wireframeSwitch').addEventListener('change', function () { if (nifViewer) nifViewer.setWireframe(this.checked); });
$('colorsSwitch').addEventListener('change', function () { if (nifViewer) nifViewer.setVertexColors(this.checked); });
$('reframeButton').addEventListener('click', function () { if (nifViewer) nifViewer.frame(); });
$('previewModal').addEventListener('hidden.bs.modal', function () {
current = null;
refs = null;
UgcIconEditor.close();
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlViewer = null;
if (readUrlItem()) writeUrl(true);
});
window.addEventListener('popstate', function () {
var item = readUrl();
load();
if (!item) {
if (current) bootstrap.Modal.getOrCreateInstance($('previewModal')).hide();
} else if (!current || current.id !== item.id) {
openItem(item.kind, item.id, item.build);
}
});
// ---- deleting and purging stored files ----
// The running or last purge (the UGC server deletes folders in the background)
function purgeLine(p) {
if (!p || !p.state) return '';
var what = (p.kind === 'modular' ? 'cars and rockets' : 'models');
var counts = esc(p.deleted) + ' deleted (' + esc(mb(p.bytes || 0)) + '), ' + esc(p.checked) + (p.total ? ' of ' + esc(p.total) : '') + ' looked at';
if (p.state === 'done') return '<div class="text-body-secondary">Last purge (' + what + '): ' + counts + ', finished ' + esc(new Date(p.finished * 1000).toLocaleString()) + '.</div>';
return '<div>' + fmt.badge(p.state === 'saving' ? 'Purge: updating the rows' : 'Purging', 'info') + ' ' + what + ': ' + counts + '. New items wait until it is done.</div>';
}
function showDeleteResult(box, d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
var text = d.started ? 'Deleting ' + (d.queued ? d.queued + ' item(s)' : 'every stored item') + ' in the background. ' + (d.notes || []).join(' ')
: (d.notes && d.notes.length && !d.deleted ? d.notes.join(' ') : d.deleted + ' deleted (' + mb(d.bytes || 0) + ' freed). ' + (d.notes || []).join(' '));
if (box) box.textContent = text;
toast(text, 'success');
load();
}
$('previewLinks').addEventListener('click', function (e) {
var button = e.target.closest('[data-delete-open]');
if (!button || !current) return;
var after = button.dataset.deleteOpen;
if (!confirm('Delete the files made for ' + current.id + (after === 'gone' ? ' and leave them deleted?' : ' and make them again?'))) return;
api.post('/api/ugc/cache/delete', { kind: current.kind, id: current.fileId, after: after }).then(function (d) { showDeleteResult(null, d); });
});
function purge(all) {
var body = { kind: list.kind, state: $('purgeState').value, owner: $('purgeOwner').value.trim(), olderThanDays: +$('purgeOlder').value || 0,
unusedDays: +$('purgeUnused').value || 0, after: $('purgeAfter').value };
if (all) {
var typed = prompt('This deletes every stored ' + (list.kind === 'model' ? 'model' : 'car and rocket') + ' file. Type PURGE ALL to go ahead.');
if (typed !== 'PURGE ALL') return;
body = { kind: list.kind, all: true, confirm: typed, after: $('purgeAfter').value };
} else if (!body.state && !body.owner && !body.olderThanDays && !body.unusedDays) {
toast('Pick a filter first, or purge all', 'warning');
return;
} else if (!confirm('Delete the stored files of every ' + (list.kind === 'model' ? 'model' : 'car and rocket') + ' matching the filter?')) {
return;
}
$('purgeResult').textContent = 'Deleting…';
api.post('/api/ugc/cache/purge', body).then(function (d) { showDeleteResult($('purgeResult'), d); });
}
$('purgeButton').addEventListener('click', function () { purge(false); });
$('purgeAllButton').addEventListener('click', function () { purge(true); });
// ---- icon presets per type (the editor is ugc-icon-editor.js) ----
function loadPresets() {
UgcIconEditor.kinds().then(function (kinds) {
// Player models have no shared preset (each is fitted to its icon): only cars and rockets have presets
kinds = kinds.filter(function (k) { return k.kind !== 'model'; });
$('presetsList').innerHTML = kinds.map(function (k) {
var sample = k.sample ? (k.kind === 'model' ? 'model ' + k.sample : 'modules ' + k.sample + (k.sampleBuilds ? ' (' + k.sampleBuilds + ' builds)' : '')) : 'nothing made yet';
return '<button type="button" class="btn btn-sm btn-outline-primary text-start" data-preset="' + esc(k.kind) + '"' + (k.sample ? '' : ' disabled') + '>' +
'<span class="fw-semibold">' + esc(k.label) + '</span><br><span class="small text-body-secondary">on ' + esc(sample) + '</span></button>';
}).join('');
$('presetsList').onclick = function (e) {
var button = e.target.closest('[data-preset]');
var entry = button && kinds.find(function (k) { return k.kind === button.dataset.preset; });
if (!entry || !entry.sample) return;
openItem(entry.kind === 'model' ? 'model' : 'modular', String(entry.sample));
};
}).catch(function (e) { $('presetsList').textContent = 'Could not load the icon types: ' + e.message; });
}
loadPresets();
// ---- processing options: Make again's choices and the comparison of what made the models ----
var OPTION_LABELS = { embree: 'Embree (CPU)', hiprt: 'HIPRT (GPU)', 'embree-gpu': 'Embree (Intel GPU)', off: 'Off', oidn: 'Open Image Denoise',
native: 'UGC server', 'toolbox-blender': 'LU Toolbox (Blender)' };
function loadOptions() {
return api.get('/api/ugc/options').then(function (d) {
if (!d.success) throw new Error(d.error || 'Failed');
[['optRays', 'rays', 'Ray tracer'], ['optDenoise', 'denoise', 'Denoise'], ['optProcessor', 'processor', 'Made by']].forEach(function (x) {
if (!d.choices[x[1]]) return;
var select = $(x[0]), chosen = select.value;
select.innerHTML = '<option value="">' + esc(x[2]) + ': settings (' + esc(OPTION_LABELS[d.defaults[x[1]]] || d.defaults[x[1]]) + ')</option>' +
d.choices[x[1]].map(function (name) { return '<option value="' + esc(name) + '">' + esc(x[2]) + ': ' + esc(OPTION_LABELS[name] || name) + '</option>'; }).join('');
select.value = chosen;
});
var ms = function (v) { return v ? esc(duration(v)) : ''; };
$('optionsTable').querySelector('tbody').innerHTML = d.combinations.length ? d.combinations.map(function (c) {
return '<tr><td><code>' + esc(c.options) + '</code></td><td>' + esc(c.runs.toLocaleString()) + '</td><td>' + esc(c.models.toLocaleString()) + '</td><td>' + ms(c.ms) +
'</td><td>' + ms(c.cpuMs) + '</td><td>' + ms(c.hsrMs) + '</td><td>' + ms(c.aoMs) + '</td><td>' + ms(c.iconMs) + '</td><td>' + esc(c.bricks) + '</td><td title="' +
esc(c.trianglesBefore.toLocaleString()) + ' before, ' + esc(c.triangles.toLocaleString()) + ' after, on average">' + esc((c.removed * 100).toFixed(1)) + '%</td></tr>';
}).join('') : '<tr><td colspan="10" class="text-body-secondary">No model made since the options were recorded.</td></tr>';
}).catch(function (e) {
$('optionsTable').querySelector('tbody').innerHTML = '<tr><td colspan="10" class="text-danger">Could not load the options: ' + esc(e.message) + '</td></tr>';
});
}
loadOptions();
$('optionsCard').addEventListener('toggle', function () { if (this.open) loadOptions(); });
if (window.Live) Live.on('ugc', load);
var linked = readUrl();
writeUrl(false, linked);
load().then(function () { if (linked) openItem(linked.kind, linked.id, linked.build); });
loadStatus();
setInterval(loadStatus, 10000);
})();