/**
* 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.
*/
(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 */ }
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 countCard(title, c) {
return '
';
}
// 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 row(i) {
var s = STATES[i.state] || [i.state, 'secondary'];
var details = (kind === 'modular' ? '' + esc(i.modules) + '' : '') + extraBadges(i);
if (i.error) details += '
' : '');
}).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 = 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 viewer ----
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
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 '
';
}
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 preview(id) {
var item = items.find(function (i) { return i.id === id; }) || { id: id, state: 'done' };
current = { id: id, kind: kind, stats: null };
$('previewTitle').textContent = (kind === 'model' ? 'Model ' : 'Car or rocket ') + id + (item.characterName ? ' by ' + item.characterName : '');
$('previewIcon').src = fileUrl(kind, id, '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');
var links = '';
if (kind === 'model') {
links += 'Download the mesh (.nif) ' +
'Download the LXFML ';
}
if (publicUrl) links += 'Open on the UGC server';
if (canManage) {
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.
';
$('framingCard').classList.toggle('d-none', model || !canManage);
if (!model && canManage) openFraming(item);
bootstrap.Modal.getOrCreateInstance($('previewModal')).show();
if (!model) 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, i) { 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; });
}
$('kindButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-kind]');
if (!button) return;
kind = button.dataset.kind;
page = 0;
this.querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b === button); });
load();
});
$('viewButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-view]');
if (!button) return;
view = button.dataset.view;
page = 0;
try { localStorage.setItem('ugcView', view); } catch (err) { /* storage blocked */ }
this.querySelectorAll('[data-view]').forEach(function (b) { b.classList.toggle('active', b === button); });
load();
});
$('viewButtons').querySelectorAll('[data-view]').forEach(function (b) { b.classList.toggle('active', b.dataset.view === view); });
$('stateFilter').addEventListener('change', function () { state = this.value; page = 0; load(); });
var searchTimer = null;
$('search').addEventListener('input', function () {
var value = this.value.trim();
clearTimeout(searchTimer);
searchTimer = setTimeout(function () { search = value; page = 0; load(); }, 300);
});
$('prevPage').addEventListener('click', function () { if (page > 0) { page--; load(); } });
$('nextPage').addEventListener('click', function () { page++; load(); });
$('retryFailed').addEventListener('click', function () { remake({ failedOnly: true }); });
$('remakeAll').addEventListener('click', function () {
remake({}, 'Make every ' + (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) preview(previewButton.dataset.preview);
}
$('rows').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;
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlViewer = null;
});
// ---- deleting and purging stored files ----
function showDeleteResult(box, d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
var text = 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.id, after: after }).then(function (d) { showDeleteResult(null, d); });
});
function purge(all) {
var body = { kind: 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 ' + (kind === 'model' ? 'model' : 'car and rocket') + ' file. Type PURGE ALL to go ahead.');
if (typed !== 'PURGE ALL') return;
body = { kind: 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 ' + (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); });
// ---- car and rocket icon framing ----
var FRAMING = [
['yaw', 'Angle around', -180, 180, 1], ['pitch', 'Angle above', -89, 89, 1], ['fov', 'Field of view', 5, 90, 0.5], ['margin', 'Border (zoom)', 0.5, 3, 0.01],
['offsetX', 'Shift right', -0.5, 0.5, 0.01], ['offsetY', 'Shift up', -0.5, 0.5, 0.01], ['sunYaw', 'Sun around', -180, 180, 1], ['sunPitch', 'Sun above', -10, 90, 1]
];
var framingItem = null, typeFraming = {}, previewTimer = null, previewUrl = null;
$('framingControls').innerHTML = FRAMING.map(function (f) {
return '
' +
'
';
}).join('');
function framingValues() {
var out = {};
FRAMING.forEach(function (f) { out[f[0]] = parseFloat($('framing_' + f[0]).value); });
return out;
}
function setFraming(values) {
FRAMING.forEach(function (f) {
if (values[f[0]] === undefined || values[f[0]] === null) return;
$('framing_' + f[0]).value = values[f[0]];
$('framing_' + f[0] + '_value').textContent = (+values[f[0]]).toFixed(f[4] < 1 ? 2 : 0);
});
}
function loadFramingType(useCombination) {
if (!framingItem) return;
var type = $('framingType').value;
return api.get('/api/ugc/framing?buildType=' + type + '&modules=' + encodeURIComponent(framingItem.modules || '')).then(function (d) {
if (!d.success) return;
typeFraming = d.type;
setFraming(d.type);
if (useCombination && d.combination) setFraming(d.combination);
$('framingState').textContent = d.combination && useCombination ? 'This combination has its own framing.' : 'The ' + d.name + ' framing.';
renderPreview();
});
}
function openFraming(item) {
framingItem = item;
// The build type from what the UGC server stored with the icon, when it has
fetchJson(fileUrl('modular', item.id, 'combo.json')).then(function (combo) {
if (combo && (combo.buildType === 3 || combo.buildType === 6)) $('framingType').value = String(combo.buildType);
loadFramingType(true);
});
}
function renderPreview() {
if (!framingItem) return;
var img = $('framingPreview');
img.style.opacity = 0.5;
fetch('/api/ugc/framing/preview', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ modules: framingItem.modules, framing: framingValues() }) }).then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'HTTP ' + r.status); });
return r.blob();
}).then(function (blob) {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(blob);
img.src = previewUrl;
img.style.opacity = 1;
}).catch(function (e) { img.style.opacity = 1; $('framingState').textContent = 'No preview: ' + e.message; });
}
$('framingControls').addEventListener('input', function (e) {
var f = FRAMING.find(function (x) { return 'framing_' + x[0] === e.target.id; });
if (f) $('framing_' + f[0] + '_value').textContent = (+e.target.value).toFixed(f[4] < 1 ? 2 : 0);
clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 350);
});
$('framingType').addEventListener('change', function () { loadFramingType(false); });
$('framingPreviewButton').addEventListener('click', renderPreview);
$('framingReset').addEventListener('click', function () {
if (!framingItem) return;
if (!confirm('Go back to the type\'s framing? This combination\'s own framing, if it has one, is removed.')) return;
api.post('/api/ugc/framing/save', { scope: 'combination', modules: framingItem.modules, framing: null }).then(function () { loadFramingType(false); });
});
$('framingSaveType').addEventListener('click', function () {
var name = $('framingType').selectedOptions[0].textContent.toLowerCase();
if (!confirm('Use this framing for every ' + name + ' icon? Icons already made keep theirs until they are drawn again.')) return;
api.post('/api/ugc/framing/save', { scope: 'type', buildType: +$('framingType').value, framing: framingValues() }).then(function (d) {
toast(d.success ? d.message : (d.error || 'Failed'), d.success ? 'success' : 'danger');
});
});
$('framingSaveCombo').addEventListener('click', function () {
if (!framingItem) return;
api.post('/api/ugc/framing/save', { scope: 'combination', modules: framingItem.modules, framing: framingValues() }).then(function (d) {
toast(d.success ? d.message : (d.error || 'Failed'), d.success ? 'success' : 'danger');
if (d.success) $('framingState').textContent = 'This combination has its own framing.';
});
});
$('framingRegenerate').addEventListener('click', function () {
var name = $('framingType').selectedOptions[0].textContent.toLowerCase();
if (!confirm('Draw every stored ' + name + ' icon again with the saved framings?')) return;
api.post('/api/ugc/framing/regenerate', { buildType: +$('framingType').value }).then(function (d) {
toast(d.success ? d.queued + ' icon' + (d.queued === 1 ? '' : 's') + ' queued' : (d.error || 'Failed'), d.success ? 'success' : 'danger');
});
});
if (window.Live) Live.on('ugc', load);
load();
loadStatus();
setInterval(loadStatus, 10000);
})();