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) =>