mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
feat(dashboard): UGC details in the property 3D view and model list
Selecting a brick-built model in the 3D view shows its UGC data under Selected: icons now and before, state, waiting until, UGC id, owner, name and description, bricks, triangles before and after hidden faces were removed, timings, and links to the UGC viewer, NIF and LXFML, with Make again for UGC managers. The property page's model rows have the same block, opened on demand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,75 @@
|
||||
(function () {
|
||||
var STATES = { done: ['Made', 'success'], pending: ['Waiting', 'secondary'], failed: ['Failed', 'danger'] };
|
||||
|
||||
function row(label, value) { return value === '' || value === null || value === undefined ? '' : '<dt class="col-5">' + esc(label) + '</dt><dd class="col-7 text-break">' + value + '</dd>'; }
|
||||
|
||||
// Triangles before and after hidden surfaces were removed, per LOD
|
||||
function triangles(stats) {
|
||||
return (stats.lods || []).map(function (l) {
|
||||
return 'LOD ' + esc(l.lod) + ': ' + esc(l.opaqueBefore) + ' → ' + esc(l.opaqueAfter) + (l.transparent ? ' + ' + esc(l.transparent) + ' transparent' : '') +
|
||||
' <span class="text-body-secondary">(' + esc(l.vertices) + ' vertices)</span>';
|
||||
}).join('<br>');
|
||||
}
|
||||
|
||||
function timings(ms) {
|
||||
if (!ms) return '';
|
||||
var parts = [['build', 'build'], ['hiddenSurfaces', 'hidden faces'], ['ambientOcclusion', 'AO'], ['icon', 'icon']]
|
||||
.filter(function (p) { return ms[p[0]] !== undefined; }).map(function (p) { return esc(p[1]) + ' ' + esc(ms[p[0]]); });
|
||||
return esc(ms.total) + ' ms' + (parts.length ? ' <span class="text-body-secondary">(' + parts.join(', ') + ')</span>' : '');
|
||||
}
|
||||
|
||||
function renderDetails(d, modelId) {
|
||||
var placement = (d.placements || []).find(function (p) { return p.modelId === String(modelId); }) || (d.placements || [])[0];
|
||||
var name = placement && placement.name && !/^Objects_\d+_name$/.test(placement.name) ? placement.name : '';
|
||||
var s = d.stats, prev = d.previousStats;
|
||||
var waiting = d.state === 'pending' && d.processAfter > Date.now() / 1000;
|
||||
var html = '<div class="ugc-details small"><div class="d-flex gap-2 mb-2 align-items-end">' +
|
||||
'<figure class="m-0 text-center"><img src="' + esc(d.icon) + '" width="96" height="96" class="rounded bg-body-tertiary" alt="" onerror="this.style.visibility=\'hidden\'"><figcaption class="text-body-secondary">Now</figcaption></figure>' +
|
||||
'<figure class="m-0 text-center d-none"><img src="' + esc(d.previousIcon) + '" width="64" height="64" class="rounded bg-body-tertiary" alt="" onload="this.parentNode.classList.remove(\'d-none\')"><figcaption class="text-body-secondary">Before · <a href="' + esc(d.previousNif) + '">NIF</a></figcaption></figure></div>' +
|
||||
'<dl class="row mb-2">' +
|
||||
row('State', UgcLinks.state(d) + (d.attempts ? ' <span class="text-body-secondary">' + esc(d.attempts) + ' attempt' + (d.attempts === 1 ? '' : 's') + '</span>' : '') +
|
||||
(d.error ? '<div class="text-danger">' + esc(d.error) + '</div>' : '')) +
|
||||
row('Waiting until', waiting ? esc(fmt.unix(d.processAfter)) + ' <span class="text-body-secondary">(the owner saved it recently)</span>' : '') +
|
||||
row('UGC id', esc(d.ugcId)) +
|
||||
row('Owner', (d.characterName ? fmt.character(d.characterId, d.characterName) : esc(d.characterId)) + (d.accountName ? ' <span class="text-body-secondary">(' + esc(d.accountName) + ')</span>' : '')) +
|
||||
row('Name', name ? esc(name) : '') +
|
||||
row('Description', placement && placement.description ? esc(placement.description) : '') +
|
||||
row('File', d.fileName ? esc(d.fileName) : '') +
|
||||
row('Made', d.processedAt ? esc(fmt.unix(d.processedAt)) : '') +
|
||||
row('Baked AO', d.bakeAo === undefined ? '' : (d.bakeAo ? 'Yes' : 'No')) +
|
||||
row('Bricks', s ? esc(s.bricks) + (prev && prev.bricks !== s.bricks ? ' <span class="text-body-secondary">(was ' + esc(prev.bricks) + ')</span>' : '') : '') +
|
||||
row('Triangles', s ? triangles(s) : '') +
|
||||
row('Time', s ? timings(s.ms) : '') +
|
||||
row('Before', prev ? esc(prev.bricks) + ' bricks<br>' + triangles(prev) : '') +
|
||||
'</dl>';
|
||||
if (d.state === 'done' && !s) html += '<div class="text-body-secondary mb-2">No stats (made before the UGC server wrote them).</div>';
|
||||
var buttons = '';
|
||||
if (d.link) buttons += '<a class="btn btn-sm btn-outline-primary" href="' + esc(d.link) + '">Open in UGC viewer</a>';
|
||||
if (d.canManage) buttons += '<button type="button" class="btn btn-sm btn-outline-warning" data-ugc-remake="' + esc(d.ugcId) + '">Make again</button>';
|
||||
if (d.state === 'done') buttons += '<a class="btn btn-sm btn-outline-secondary" href="' + esc(d.nif) + '">Download NIF</a>';
|
||||
if (modelId) buttons += '<a class="btn btn-sm btn-outline-secondary" href="/api/property_models/' + esc(modelId) + '/lxfml">Download LXFML</a>';
|
||||
return html + (buttons ? '<div class="d-flex flex-wrap gap-1">' + buttons + '</div>' : '') + '</div>';
|
||||
}
|
||||
|
||||
// "Make again" in any UGC block
|
||||
document.addEventListener('click', function (e) {
|
||||
var button = e.target.closest && e.target.closest('[data-ugc-remake]');
|
||||
if (!button) return;
|
||||
button.disabled = true;
|
||||
api.post('/api/ugc/reprocess', { kind: 'model', id: button.dataset.ugcRemake }).then(function (d) {
|
||||
toast(d.success ? 'The UGC server will make it again' : (d.error || 'Failed'), d.success ? 'success' : 'danger');
|
||||
}).catch(function () { toast('Failed', 'danger'); }).then(function () { button.disabled = false; });
|
||||
});
|
||||
|
||||
// Expandable blocks (<details data-ugc-details="ugcId" data-model="modelId" data-via="property=...">) load when first opened
|
||||
document.addEventListener('toggle', function (e) {
|
||||
var box = e.target;
|
||||
if (!box.matches || !box.matches('details[data-ugc-details]') || !box.open || box.dataset.loaded) return;
|
||||
box.dataset.loaded = '1';
|
||||
var body = box.querySelector('.ugc-details-body');
|
||||
UgcLinks.details(box.dataset.ugcDetails, box.dataset.via, box.dataset.model).then(function (html) { body.innerHTML = html; });
|
||||
}, true);
|
||||
|
||||
window.UgcLinks = {
|
||||
// A badge for the UGC server's state; a failure's reason as its tooltip
|
||||
state: function (item) {
|
||||
@@ -18,7 +87,19 @@
|
||||
return '<img src="' + esc(item.icon) + '" width="' + size + '" height="' + size + '" class="rounded bg-body-tertiary" alt="" loading="lazy" ' +
|
||||
'onerror="this.onerror=null;this.src=\'/api/icon/' + esc(lot) + '\'">';
|
||||
},
|
||||
// {modelId: item} for the player-built models on a property
|
||||
/**
|
||||
* The UGC block for a brick-built model on the property pages: its icons (and the previous version's), state,
|
||||
* owner, the name the player gave it, the UGC server's stats and links. `via` is the page's ?property= / ?character=
|
||||
* query that lets its viewer see it; `modelId` (a placed model) picks its name and LXFML download.
|
||||
*/
|
||||
details: function (ugcId, via, modelId) {
|
||||
if (!ugcId || ugcId === '0') return Promise.resolve('<div class="small text-body-secondary">No UGC data: this model has no blueprint.</div>');
|
||||
return api.get('/api/ugc_links/model/' + encodeURIComponent(ugcId) + (via ? '?' + via : '')).then(function (d) {
|
||||
if (!d || d.success === false) return '<div class="small text-body-secondary">' + esc((d && d.error) || 'No UGC data for this model') + '</div>';
|
||||
return renderDetails(d, modelId);
|
||||
}).catch(function (e) { return '<div class="small text-body-secondary">' + esc((e && e.message) || 'Could not load the UGC data') + '</div>'; });
|
||||
},
|
||||
// {modelId: item} for the player-built models on a property // {modelId: item} for the player-built models on a property
|
||||
forProperty: function (propertyId) {
|
||||
return api.get('/api/ugc_links/property/' + propertyId).then(function (d) {
|
||||
var map = {};
|
||||
|
||||
@@ -217,7 +217,22 @@ function showSelection(model, index) {
|
||||
'<dt class="col-4">Model ID</dt><dd class="col-8 text-break">' + esc(model.id) + '</dd>' +
|
||||
'<dt class="col-4">Position</dt><dd class="col-8">' + model.position.map((v) => esc(v.toFixed(2))).join(', ') + '</dd>' +
|
||||
'<dt class="col-4">Rotation</dt><dd class="col-8">' + esc(yaw) + '°</dd>' + ugcInfo(model) + '</dl>' +
|
||||
(showcase ? '' : '<a class="btn btn-sm btn-outline-secondary" href="/api/property_models/' + esc(model.id) + '/lxfml">Download LXFML</a>');
|
||||
(showcase ? '' : brickBuilt(model) ? '<h6 class="mt-2 mb-1">UGC</h6><div id="ugcDetails" class="small text-body-secondary">Loading…</div>'
|
||||
: '<a class="btn btn-sm btn-outline-secondary" href="/api/property_models/' + esc(model.id) + '/lxfml">Download LXFML</a>');
|
||||
if (!showcase && brickBuilt(model)) {
|
||||
const item = ugc[model.id];
|
||||
const placed = property && property.models.find((m) => String(m.id) === String(model.id));
|
||||
UgcLinks.details(item ? item.ugcId : placed && placed.ugc_id, 'property=' + propertyId, model.id).then((html) => {
|
||||
const box = document.getElementById('ugcDetails');
|
||||
if (box && selectedIndex === index) { box.className = ''; box.innerHTML = html; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Brick-built models (their blueprint is on the UGC server); cars and rockets are modular and drawn by the client
|
||||
function brickBuilt(model) {
|
||||
const item = ugc[model.id];
|
||||
return item ? item.kind === 'model' : model.lot === 14;
|
||||
}
|
||||
|
||||
// A player-built model's UGC server state, what it is drawn from, and a link to it in the UGC viewer
|
||||
|
||||
@@ -217,6 +217,12 @@ function ugcState(m) {
|
||||
return '<div class="small mt-1">UGC server: ' + UgcLinks.state(m.ugc) + (m.ugc.link ? ' <a href="' + esc(m.ugc.link) + '">Open in UGC viewer</a>' : '') + '</div>';
|
||||
}
|
||||
|
||||
// A brick-built model's full UGC data (icons, stats, links), loaded when opened (ugc-links.js)
|
||||
function ugcDetails(m) {
|
||||
return '<details class="mt-1" data-ugc-details="' + esc(m.ugc_id || '') + '" data-model="' + esc(m.id) + '" data-via="property=' + esc(propertyId) + '">' +
|
||||
'<summary class="small">UGC details</summary><div class="ugc-details-body mt-1 small text-body-secondary">Loading…</div></details>';
|
||||
}
|
||||
|
||||
const labels = $('#modelsTable thead th').map(function () { return $(this).text().trim(); }).get();
|
||||
const columns = [
|
||||
{ data: null, render: (m, type) => {
|
||||
@@ -236,13 +242,13 @@ const columns = [
|
||||
{ data: null, render: (m, type) => {
|
||||
if (m.lxfml_size === null || m.lxfml_size === undefined) {
|
||||
if (type !== 'display') return m.ldf_config || '';
|
||||
return m.ldf_config ? '<span class="small">Modular: <code>' + esc(m.ldf_config) + '</code></span>' + ugcState(m) : muted(m.lot === UGC_LOT ? 'Missing' : 'Prebuilt');
|
||||
return m.ldf_config ? '<span class="small">Modular: <code>' + esc(m.ldf_config) + '</code></span>' + ugcState(m) : (m.lot === UGC_LOT ? muted('Missing') + ugcDetails(m) : muted('Prebuilt'));
|
||||
}
|
||||
if (type !== 'display') return m.lxfml_size;
|
||||
return '<span class="small">' + esc(bytes(m.lxfml_size)) + '</span> ' +
|
||||
(m.is_optimized ? fmt.badge('Optimized', 'info') + ' ' : '') + (m.bake_ao ? fmt.badge('Baked AO', 'secondary') + ' ' : '') +
|
||||
(m.ldf_config ? '<div class="small">Modular: <code>' + esc(m.ldf_config) + '</code></div>' : '') +
|
||||
'<div class="small text-body-secondary">UGC ' + esc(m.ugc_id) + (m.filename ? ' · ' + esc(m.filename) : '') + '</div>' + ugcState(m);
|
||||
'<div class="small text-body-secondary">UGC ' + esc(m.ugc_id) + (m.filename ? ' · ' + esc(m.filename) : '') + '</div>' + ugcState(m) + (!m.ugc || m.ugc.kind === 'model' ? ugcDetails(m) : '');
|
||||
} },
|
||||
{ data: 'position', orderable: false, render: (d) => '<span class="small text-nowrap">' + d.map((v) => v.toFixed(1)).join(', ') + '</span>' },
|
||||
{ data: 'id', orderable: false, className: 'actions', render: (d) =>
|
||||
|
||||
Reference in New Issue
Block a user