From 28dfc7f191c4f4143abb2aeedda692600f67e2f7 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 15:23:40 -0500 Subject: [PATCH] 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 --- dDashboardServer/static/js/ugc-links.js | 83 ++++++++++++++++++- dDashboardServer/templates/property-3d.jinja2 | 17 +++- .../templates/property-view.jinja2 | 10 ++- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/dDashboardServer/static/js/ugc-links.js b/dDashboardServer/static/js/ugc-links.js index e47b8803f..6ff0013fd 100644 --- a/dDashboardServer/static/js/ugc-links.js +++ b/dDashboardServer/static/js/ugc-links.js @@ -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 ? '' : '
' + esc(label) + '
' + value + '
'; } + + // 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' : '') + + ' (' + esc(l.vertices) + ' vertices)'; + }).join('
'); + } + + 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 ? ' (' + parts.join(', ') + ')' : ''); + } + + 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 = '
' + + '
Now
' + + '
Before · NIF
' + + '
' + + row('State', UgcLinks.state(d) + (d.attempts ? ' ' + esc(d.attempts) + ' attempt' + (d.attempts === 1 ? '' : 's') + '' : '') + + (d.error ? '
' + esc(d.error) + '
' : '')) + + row('Waiting until', waiting ? esc(fmt.unix(d.processAfter)) + ' (the owner saved it recently)' : '') + + row('UGC id', esc(d.ugcId)) + + row('Owner', (d.characterName ? fmt.character(d.characterId, d.characterName) : esc(d.characterId)) + (d.accountName ? ' (' + esc(d.accountName) + ')' : '')) + + 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 ? ' (was ' + esc(prev.bricks) + ')' : '') : '') + + row('Triangles', s ? triangles(s) : '') + + row('Time', s ? timings(s.ms) : '') + + row('Before', prev ? esc(prev.bricks) + ' bricks
' + triangles(prev) : '') + + '
'; + if (d.state === 'done' && !s) html += '
No stats (made before the UGC server wrote them).
'; + var buttons = ''; + if (d.link) buttons += 'Open in UGC viewer'; + if (d.canManage) buttons += ''; + if (d.state === 'done') buttons += 'Download NIF'; + if (modelId) buttons += 'Download LXFML'; + return html + (buttons ? '
' + buttons + '
' : '') + '
'; + } + + // "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 (
) 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 ''; }, - // {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('
No UGC data: this model has no blueprint.
'); + return api.get('/api/ugc_links/model/' + encodeURIComponent(ugcId) + (via ? '?' + via : '')).then(function (d) { + if (!d || d.success === false) return '
' + esc((d && d.error) || 'No UGC data for this model') + '
'; + return renderDetails(d, modelId); + }).catch(function (e) { return '
' + esc((e && e.message) || 'Could not load the UGC data') + '
'; }); + }, + // {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 = {}; diff --git a/dDashboardServer/templates/property-3d.jinja2 b/dDashboardServer/templates/property-3d.jinja2 index 0d3080b60..aaf94bf88 100644 --- a/dDashboardServer/templates/property-3d.jinja2 +++ b/dDashboardServer/templates/property-3d.jinja2 @@ -217,7 +217,22 @@ function showSelection(model, index) { '
Model ID
' + esc(model.id) + '
' + '
Position
' + model.position.map((v) => esc(v.toFixed(2))).join(', ') + '
' + '
Rotation
' + esc(yaw) + '°
' + ugcInfo(model) + '' + - (showcase ? '' : 'Download LXFML'); + (showcase ? '' : brickBuilt(model) ? '
UGC
Loading…
' + : 'Download LXFML'); + 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 diff --git a/dDashboardServer/templates/property-view.jinja2 b/dDashboardServer/templates/property-view.jinja2 index 03cf8bb53..12f74852c 100644 --- a/dDashboardServer/templates/property-view.jinja2 +++ b/dDashboardServer/templates/property-view.jinja2 @@ -217,6 +217,12 @@ function ugcState(m) { return '
UGC server: ' + UgcLinks.state(m.ugc) + (m.ugc.link ? ' Open in UGC viewer' : '') + '
'; } +// A brick-built model's full UGC data (icons, stats, links), loaded when opened (ugc-links.js) +function ugcDetails(m) { + return '
' + + 'UGC details
Loading…
'; +} + 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 ? 'Modular: ' + esc(m.ldf_config) + '' + ugcState(m) : muted(m.lot === UGC_LOT ? 'Missing' : 'Prebuilt'); + return m.ldf_config ? 'Modular: ' + esc(m.ldf_config) + '' + ugcState(m) : (m.lot === UGC_LOT ? muted('Missing') + ugcDetails(m) : muted('Prebuilt')); } if (type !== 'display') return m.lxfml_size; return '' + esc(bytes(m.lxfml_size)) + ' ' + (m.is_optimized ? fmt.badge('Optimized', 'info') + ' ' : '') + (m.bake_ao ? fmt.badge('Baked AO', 'secondary') + ' ' : '') + (m.ldf_config ? '
Modular: ' + esc(m.ldf_config) + '
' : '') + - '
UGC ' + esc(m.ugc_id) + (m.filename ? ' · ' + esc(m.filename) : '') + '
' + ugcState(m); + '
UGC ' + esc(m.ugc_id) + (m.filename ? ' · ' + esc(m.filename) : '') + '
' + ugcState(m) + (!m.ugc || m.ugc.kind === 'model' ? ugcDetails(m) : ''); } }, { data: 'position', orderable: false, render: (d) => '' + d.map((v) => v.toFixed(1)).join(', ') + '' }, { data: 'id', orderable: false, className: 'actions', render: (d) =>