diff --git a/dDashboardServer/static/js/ugc.js b/dDashboardServer/static/js/ugc.js
index e73657edc..edddabf8a 100644
--- a/dDashboardServer/static/js/ugc.js
+++ b/dDashboardServer/static/js/ugc.js
@@ -1,88 +1,188 @@
/**
- * The UGC Server page: counts, a gallery and a list of what the UGC server made of players' models and modular builds
- * (from the database), its live status, and a viewer comparing a model's generated mesh, its LXFML and its icon, now
- * and before it was made again. Everything from the UGC server comes through the dashboard (/api/ugc/server/status,
- * /api/ugc/files/..., /api/ugc/mesh/...), so the browser never has to reach the UGC server itself.
+ * 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 kind = 'model', state = '', search = '', page = 0, view = 'gallery', canManage = false, publicUrl = '';
- var nifViewer = null, lxfmlViewer = null, current = null;
var STATES = { pending: ['Waiting', 'secondary'], done: ['Made', 'success'], failed: ['Failed', 'danger'], empty: ['Empty', 'light'] };
- var PAGE_SIZE = { gallery: 48, list: 50 };
-
- try { view = localStorage.getItem('ugcView') === 'list' ? 'list' : 'gallery'; } catch (e) { /* storage blocked */ }
+ var SORTS = {
+ model: [['newest', 'Newest'], ['oldest', 'Oldest'], ['bricks', 'Most bricks'], ['triangles', 'Most triangles'], ['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; }
- function fileUrl(itemKind, id, name) {
- return '/api/ugc/files/' + itemKind + '/' + encodeURIComponent(id) + '/' + name;
+ // ---- 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';
+ $('pageSize').value = String(pageSize());
}
- function mb(bytes) { return (bytes / 1048576).toFixed(0) + ' MB'; }
+ // ---- the list ----
function countCard(title, c) {
return '
';
+ 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);
+ return '
' + iconImg(i, 128) +
+ '
' + title + '
' + sub + '
' + badge(i.state) + extra + '
';
}
-
- // Cars and rockets: how many builds share this icon; waiting models: when their quiet period after a save ends
- function extraBadges(i) {
- var out = '';
- if (i.sharedBy > 1) out += ' ' + fmt.badge('shared by ' + i.sharedBy, 'info');
- if (i.state === 'pending' && i.processAfter > Date.now() / 1000) out += ' after ' + esc(fmt.unix(i.processAfter)) + '';
- return out;
+ function listHead() {
+ return list.kind === 'modular' ? '
Icon
Type
Modules
State
Builds
Owners
'
+ : '
Icon
ID
Owner
State
Made
Size
Details
';
}
-
function row(i) {
- var s = STATES[i.state] || [i.state, 'secondary'];
- var details = (kind === 'modular' ? '' + esc(i.modules) + '' : '') + extraBadges(i);
- if (i.error) details += '
';
}
- var items = [];
+ // 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 '
';
+ }
+
+ 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 = fileUrl(kind, id, 'previous.icon.png');
+ previous.src = modular && !item.iconBuild ? '' : fileUrl(list.kind, current.fileId, 'previous.icon.png');
var links = '';
- if (kind === 'model') {
+ if (!modular) {
links += 'Download the mesh (.nif) ' +
'Download the LXFML ';
}
- if (publicUrl) links += 'Open on the UGC server';
- if (canManage) {
- links += '
' +
+ if (publicUrl && current.fileId) links += 'Open on the UGC server';
+ if (canManage && current.fileId) {
+ links += '
' +
'' +
'
';
- if (item.sharedBy > 1) links += '
This icon is shared by ' + esc(item.sharedBy) + ' builds of the same modules.
';
}
if (item.error) links += '
' + esc(item.error) + '
';
$('previewLinks').innerHTML = links;
- var model = kind === 'model';
- ['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); });
- $('statsBox').innerHTML = model ? '' : '
Modules: ' + esc(item.modules || '') + ' (combination ' + esc(item.combination || '') +
- '). The game client puts cars and rockets together itself; only the icon is made, once per combination of modules.
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 (!model) return;
+ 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;
@@ -247,12 +337,11 @@
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, i) { return ''; }).join('');
+ $('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) {
@@ -270,38 +359,89 @@
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';
+ }
+ function loadRefs() {
+ if (!refs) return;
+ var wanted = refs;
+ $('refRows').innerHTML = '