feat(dashboard): UGC page lists assemblies, numbered pages, prefix search and sorting in the address

Cars and rockets are listed one per combination of modules, with their type,
module names and icons and how many builds use them; an assembly opens with the
icon editor and a References panel (its builds, owners, accounts and where
they are, paged and searchable). A build's link opens its assembly with the
build highlighted. Both lists page on the server with totals, numbered pages,
first/last, jump to page and a page size kept per user; the search box takes
field prefixes; filters, sort and page are kept in the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 15:46:47 -05:00
parent 6498582b50
commit fbf923e043
2 changed files with 304 additions and 153 deletions

View File

@@ -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 * The UGC Server page: counts, the UGC server's live status, and what it made: player models, and cars and rockets as
* (from the database), its live status, and a viewer comparing a model's generated mesh, its LXFML and its icon, now * assemblies (one per combination of modules, however many builds use it), paged on the server with a search box
* and before it was made again. Everything from the UGC server comes through the dashboard (/api/ugc/server/status, * (field prefixes), filters and sorting, all kept in the address so Back and links work. An item opens with its
* /api/ugc/files/..., /api/ugc/mesh/...), so the browser never has to reach the UGC server itself. * 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 () { (function () {
'use strict'; '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 STATES = { pending: ['Waiting', 'secondary'], done: ['Made', 'success'], failed: ['Failed', 'danger'], empty: ['Empty', 'light'] };
var PAGE_SIZE = { gallery: 48, list: 50 }; var SORTS = {
model: [['newest', 'Newest'], ['oldest', 'Oldest'], ['bricks', 'Most bricks'], ['triangles', 'Most triangles'], ['owner', 'Owner'], ['name', 'File name']],
try { view = localStorage.getItem('ugcView') === 'list' ? 'list' : 'gallery'; } catch (e) { /* storage blocked */ } 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 $(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) { // ---- the address ----
return '/api/ugc/files/' + itemKind + '/' + encodeURIComponent(id) + '/' + name;
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 '<option value="' + o[0] + '">' + esc(o[1]) + '</option>'; }).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) { function countCard(title, c) {
return '<div class="col-md-6"><div class="card"><div class="card-body py-2"><div class="small text-body-secondary">' + esc(title) + '</div>' + return '<div class="col-md-6"><div class="card"><div class="card-body py-2"><div class="small text-body-secondary">' + esc(title) + '</div>' +
fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') + fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') +
(c.empty ? ' ' + fmt.badge(c.empty + ' empty', 'light') : '') + '</div></div></div>'; (c.empty ? ' ' + fmt.badge(c.empty + ' empty', 'light') : '') + '</div></div></div>';
} }
function owner(i) { function owner(i) {
return i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>'; return i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
} }
function badge(state) { var s = STATES[state] || [state, 'secondary']; return fmt.badge(s[0], s[1]); }
// The id whose files show an item's icon: a model's own, an assembly's made build's (the UGC server finds its combination)
function iconId(i) { return list.kind === 'modular' ? i.iconBuild : i.id; }
function iconImg(i, size) { function iconImg(i, size) {
if (i.state !== 'done') return '<span class="ugc-noicon text-body-secondary small border rounded" style="width:' + size + 'px;height:' + size + 'px">' + esc((STATES[i.state] || [i.state])[0]) + '</span>'; if (i.state !== 'done') return '<span class="ugc-noicon text-body-secondary small border rounded" style="width:' + size + 'px;height:' + size + 'px">' + esc((STATES[i.state] || [i.state])[0]) + '</span>';
return '<img src="' + esc(fileUrl(kind, i.id, 'icon.png')) + '" width="' + size + '" height="' + size + '" loading="lazy" alt="" class="ugc-checker rounded" onerror="this.style.visibility=\'hidden\'">'; return '<img src="' + esc(fileUrl(list.kind, iconId(i), 'icon.png')) + '" width="' + size + '" height="' + size + '" loading="lazy" alt="" class="ugc-checker rounded" onerror="this.style.visibility=\'hidden\'">';
}
function moduleNames(i) { return (i.moduleList || []).map(function (m) { return m.name || m.lot; }).join(', '); }
function waitBadge(i) {
return i.state === 'pending' && i.processAfter > Date.now() / 1000 ? ' <span class="small text-body-secondary" title="Waits for the owner to stop saving">after ' + esc(fmt.unix(i.processAfter)) + '</span>' : '';
} }
function tile(i) { function tile(i) {
var s = STATES[i.state] || [i.state, 'secondary']; var title = list.kind === 'modular' ? esc(i.kindLabel || 'Build type ' + i.buildType) : (i.characterName ? esc(i.characterName) : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>');
return '<div class="card ugc-tile p-2" tabindex="0" role="button" data-preview="' + esc(i.id) + '" title="' + esc(i.error || '') + '">' + iconImg(i, 128) + var sub = list.kind === 'modular' ? esc(moduleNames(i)) : esc(i.id);
'<div class="small text-truncate mt-1">' + (i.characterName ? esc(i.characterName) : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>') + '</div>' + var extra = list.kind === 'modular' ? ' ' + fmt.badge(i.uses + ' build' + (i.uses === 1 ? '' : 's'), 'info') : waitBadge(i);
'<div class="small text-body-secondary text-truncate">' + esc(i.id) + '</div><div>' + fmt.badge(s[0], s[1]) + extraBadges(i) + '</div></div>'; return '<div class="card ugc-tile p-2" tabindex="0" role="button" data-preview="' + esc(i.id) + '" title="' + esc(i.error || sub) + '">' + iconImg(i, 128) +
'<div class="small text-truncate mt-1">' + title + '</div><div class="small text-body-secondary text-truncate">' + sub + '</div><div>' + badge(i.state) + extra + '</div></div>';
} }
function listHead() {
// Cars and rockets: how many builds share this icon; waiting models: when their quiet period after a save ends return list.kind === 'modular' ? '<tr><th>Icon</th><th>Type</th><th>Modules</th><th>State</th><th>Builds</th><th>Owners</th><th></th></tr>'
function extraBadges(i) { : '<tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Made</th><th>Size</th><th>Details</th><th></th></tr>';
var out = '';
if (i.sharedBy > 1) out += ' ' + fmt.badge('shared by ' + i.sharedBy, 'info');
if (i.state === 'pending' && i.processAfter > Date.now() / 1000) out += ' <span class="small text-body-secondary" title="Waits for the owner to stop saving">after ' + esc(fmt.unix(i.processAfter)) + '</span>';
return out;
} }
function row(i) { function row(i) {
var s = STATES[i.state] || [i.state, 'secondary']; var view = '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>';
var details = (kind === 'modular' ? '<code class="small">' + esc(i.modules) + '</code>' : '') + extraBadges(i); var error = i.error ? '<div class="small text-danger">' + esc(i.error) + '</div>' : '';
if (i.error) details += '<div class="small text-danger">' + esc(i.error) + '</div>'; if (list.kind === 'modular') {
var buttons = '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>' + return '<tr><td>' + iconImg(i, 48) + '</td><td>' + esc(i.kindLabel || i.buildType) + '</td><td class="small">' + esc(moduleNames(i)) + '<div><code>' + esc(i.key) + '</code></div>' + error +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.id) + '">Make again</button>' : ''); '</td><td>' + badge(i.state) + '</td><td>' + esc(i.uses) + '</td><td>' + esc(i.owners) + '</td><td class="text-end text-nowrap">' + view +
return '<tr><td>' + iconImg(i, 48) + '</td><td class="small">' + esc(i.id) + '</td><td>' + owner(i) + '</td><td>' + fmt.badge(s[0], s[1]) + (canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.iconBuild) + '">Make again</button>' : '') + '</td></tr>';
(i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + '</td>' + }
'<td class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</td><td>' + details + '</td><td class="text-end text-nowrap">' + buttons + '</td></tr>'; return '<tr><td>' + iconImg(i, 48) + '</td><td class="small">' + esc(i.id) + '</td><td>' + owner(i) + '</td><td>' + badge(i.state) +
(i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + waitBadge(i) + '</td>' +
'<td class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</td><td class="small">' + (i.bricks ? esc(i.bricks) + ' bricks<br>' + esc(i.triangles.toLocaleString()) + ' triangles' : '') +
'</td><td class="small">' + esc(i.detail || '') + error + '</td><td class="text-end text-nowrap">' + view +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.id) + '">Make again</button>' : '') + '</td></tr>';
} }
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 '<li class="page-item' + (disabled ? ' disabled' : '') + (active ? ' active' : '') + '"><a class="page-link" href="#" data-page="' + p + '"' +
(active ? ' aria-current="page"' : '') + '>' + label + '</a></li>';
};
var html = li(0, '&laquo;', page === 0) + li(Math.max(0, page - 1), '&lsaquo;', page === 0);
if (shown[0] > 0) html += li(0, '1') + (shown[0] > 1 ? '<li class="page-item disabled"><span class="page-link">&hellip;</span></li>' : '');
shown.forEach(function (p) { html += li(p, p + 1, false, p === page); });
if (shown[shown.length - 1] < pages - 1) html += (shown[shown.length - 1] < pages - 2 ? '<li class="page-item disabled"><span class="page-link">&hellip;</span></li>' : '') + li(pages - 1, pages);
html += li(Math.min(pages - 1, page + 1), '&rsaquo;', page >= pages - 1) + li(pages - 1, '&raquo;', page >= pages - 1);
el.innerHTML = html;
el.onclick = function (e) {
var a = e.target.closest('[data-page]');
if (!a || a.parentElement.classList.contains('disabled')) return;
e.preventDefault();
onPage(+a.dataset.page);
};
}
var loadSequence = 0;
function load() { function load() {
var size = PAGE_SIZE[view]; var size = pageSize(), sequence = ++loadSequence;
api.get('/api/ugc?kind=' + kind + '&state=' + state + '&page=' + page + '&size=' + size + '&search=' + encodeURIComponent(search)).then(function (d) { showControls();
if (!d.success) return; var query = 'kind=' + list.kind + '&q=' + encodeURIComponent(list.q) + '&state=' + list.state + '&type=' + encodeURIComponent(list.kind === 'modular' ? list.type : '') +
'&sort=' + list.sort + '&page=' + list.page + '&size=' + size;
return api.get('/api/ugc?' + query).then(function (d) {
if (!d.success || sequence !== loadSequence) return;
items = d.items; items = d.items;
total = d.total;
canManage = d.canManage; canManage = d.canManage;
publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, ''); publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, '');
if (d.kinds && !kinds.length) {
kinds = d.kinds;
$('typeFilter').innerHTML = '<option value="">Every type</option>' + kinds.filter(function (k) { return k.buildType !== undefined; })
.map(function (k) { return '<option value="' + esc(k.kind) + '">' + esc(k.label) + '</option>'; }).join('');
$('typeFilter').value = list.type;
}
$('manageButtons').classList.toggle('d-none', !canManage); $('manageButtons').classList.toggle('d-none', !canManage);
$('cacheCard').classList.toggle('d-none', !canManage); $('cacheCard').classList.toggle('d-none', !canManage);
$('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets', d.counts.modular); $('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular);
$('gallery').classList.toggle('d-none', view !== 'gallery'); $('gallery').classList.toggle('d-none', list.view !== 'gallery');
$('listCard').classList.toggle('d-none', view !== 'list'); $('listCard').classList.toggle('d-none', list.view !== 'list');
if (view === 'gallery') $('gallery').innerHTML = d.items.map(tile).join('') || '<div class="text-body-secondary">Nothing here.</div>'; var nothing = list.q || list.state || list.type ? 'Nothing matches.' : 'Nothing here.';
else $('rows').innerHTML = d.items.map(row).join('') || '<tr><td colspan="7" class="text-body-secondary">Nothing here.</td></tr>'; if (list.view === 'gallery') $('gallery').innerHTML = items.map(tile).join('') || '<div class="text-body-secondary">' + nothing + '</div>';
$('prevPage').disabled = page === 0; else {
$('nextPage').disabled = !d.more; $('listHead').innerHTML = listHead();
$('rows').innerHTML = items.map(row).join('') || '<tr><td colspan="8" class="text-body-secondary">' + nothing + '</td></tr>';
}
var pages = Math.max(1, Math.ceil(total / size));
pager($('pager'), list.page, pages, function (p) { list.page = p; writeUrl(true); load(); });
$('jumpPage').max = pages;
$('jumpPage').placeholder = (list.page + 1) + ' / ' + pages;
$('totalText').textContent = total.toLocaleString() + (list.kind === 'modular' ? ' assembl' + (total === 1 ? 'y' : 'ies') : ' model' + (total === 1 ? '' : 's'));
}).catch(function () {}); }).catch(function () {});
} }
function changeList(changes, push) {
for (var k in changes) list[k] = changes[k];
writeUrl(push);
load();
}
function meter(label, value, limit, text) { function meter(label, value, limit, text) {
var share = limit > 0 ? Math.min(100, value / limit * 100) : 0; var share = limit > 0 ? Math.min(100, value / limit * 100) : 0;
var colour = share > 90 ? 'danger' : share > 70 ? 'warning' : 'success'; var colour = share > 90 ? 'danger' : share > 70 ? 'warning' : 'success';
@@ -119,7 +219,7 @@
function remake(body, question) { function remake(body, question) {
if (question && !confirm(question)) return; if (question && !confirm(question)) return;
body.kind = kind; body.kind = list.kind;
api.post('/api/ugc/reprocess', body).then(function (d) { api.post('/api/ugc/reprocess', body).then(function (d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; } if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
toast(d.message, 'success'); toast(d.message, 'success');
@@ -127,11 +227,7 @@
}); });
} }
// ---- the viewer ---- // ---- the open item ----
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) { function statsTable(now, before) {
if (!now) return '<div class="small text-body-secondary">No stats for this model (made before the UGC server wrote them).</div>'; if (!now) return '<div class="small text-body-secondary">No stats for this model (made before the UGC server wrote them).</div>';
@@ -168,78 +264,72 @@
}).catch(function (e) { stats.textContent = 'Could not load the mesh: ' + e.message; }); }).catch(function (e) { stats.textContent = 'Could not load the mesh: ' + e.message; });
} }
// ---- the open item in the address bar (?item=<id>&kind=model|modular), so it can be shared and Back closes it ---- function openItem(itemKind, id, build) {
if (list.kind !== itemKind) changeList({ kind: itemKind, page: 0, sort: 'newest', type: '' }, true);
var pushedItem = false; var known = items.find(function (i) { return i.id === id; });
if (known) return preview(known, build);
function urlItem() { if (itemKind === 'modular' && /^\d+$/.test(id) && id.length > 12) {
var params = new URLSearchParams(location.search), id = params.get('item'); // A build's id (links from elsewhere): open the assembly it uses, with the build shown in References
if (!id || !/^\d{1,20}$/.test(id)) return null; return api.get('/api/ugc/assembly/of/' + id).then(function (d) {
return { id: id, kind: params.get('kind') === 'modular' ? 'modular' : 'model' }; if (!d.success) { toast(d.error || 'No such build', 'warning'); return; }
} findAndOpen('modular', d.key, id);
});
function setKind(value) {
if (value === kind) return;
kind = value;
page = 0;
$('kindButtons').querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b.dataset.kind === kind); });
load();
}
// Opens the item the address names, or closes the open one when it names none
function openFromUrl() {
var wanted = urlItem();
if (!wanted) {
if (current) bootstrap.Modal.getOrCreateInstance($('previewModal')).hide();
return;
} }
setKind(wanted.kind); findAndOpen(itemKind, id, build);
if (current && current.id === wanted.id && current.kind === wanted.kind) return; }
var known = items.find(function (i) { return i.id === wanted.id; }); // Not on the page showing: looked up by its id (a model's) or its modules (an assembly's)
if (known) return preview(wanted.id, known); function findAndOpen(itemKind, id, build) {
// Not on the page of the list that is showing: look it up by its id var q = itemKind === 'modular' ? 'lot:' + id.split('-')[0] : 'id:' + id;
api.get('/api/ugc?kind=' + wanted.kind + '&search=' + wanted.id + '&size=10').then(function (d) { api.get('/api/ugc?kind=' + itemKind + '&q=' + encodeURIComponent(q) + '&size=200').then(function (d) {
canManage = d.canManage; canManage = d.canManage;
publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, ''); var found = (d.items || []).find(function (i) { return i.id === id; });
preview(wanted.id, (d.items || []).find(function (i) { return i.id === wanted.id; })); preview(found || (itemKind === 'modular' ? { id: id, key: id, modules: id.replace(/-/g, '+'), moduleList: [], state: 'pending', uses: 0 } : { id: id, state: 'done' }), build);
}).catch(function () { preview(wanted.id); }); }).catch(function () {});
} }
function preview(id, found) { function modulesHtml(i) {
var item = found || items.find(function (i) { return i.id === id; }) || { id: id, state: 'done' }; return '<div class="d-flex flex-wrap gap-2 ugc-modules">' + (i.moduleList || []).map(function (m) {
current = { id: id, kind: kind, stats: null }; return '<div class="d-flex align-items-center gap-1 border rounded px-1"><img src="' + esc(m.icon) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">' +
var shown = urlItem(); '<div><div>' + esc(m.name || 'LOT ' + m.lot) + '</div><div class="text-body-secondary">' + (DASH.can('dev_cdclient') ? '<a href="/cdclient#/object/' + esc(m.lot) + '">LOT ' + esc(m.lot) + '</a>' : 'LOT ' + esc(m.lot)) + '</div></div></div>';
if (!shown || shown.id !== id || shown.kind !== kind) { }).join('') + '</div>';
history.pushState(null, '', location.pathname + '?item=' + encodeURIComponent(id) + '&kind=' + kind); }
pushedItem = true;
} function preview(item, build) {
$('previewTitle').textContent = (kind === 'model' ? 'Model ' : 'Car or rocket ') + id + (item.characterName ? ' by ' + item.characterName : ''); var modular = list.kind === 'modular', id = item.id;
$('previewIcon').src = fileUrl(kind, id, 'icon.png'); 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'); $('previousIconBox').classList.add('d-none');
var previous = $('previousIcon'); var previous = $('previousIcon');
previous.onload = function () { $('previousIconBox').classList.remove('d-none'); }; 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 = ''; var links = '';
if (kind === 'model') { if (!modular) {
links += '<a href="' + esc(fileUrl('model', id, 'model.nif')) + '" download="ugc_' + esc(id) + '.nif">Download the mesh (.nif)</a><br>' + links += '<a href="' + esc(fileUrl('model', id, 'model.nif')) + '" download="ugc_' + esc(id) + '.nif">Download the mesh (.nif)</a><br>' +
'<a href="/api/ugc/' + esc(id) + '/lxfml" download="ugc_' + esc(id) + '.lxfml">Download the LXFML</a><br>'; '<a href="/api/ugc/' + esc(id) + '/lxfml" download="ugc_' + esc(id) + '.lxfml">Download the LXFML</a><br>';
} }
if (publicUrl) links += '<a href="' + esc(publicUrl + '/files/' + kind + '/' + id + '/icon.png') + '" target="_blank" rel="noopener">Open on the UGC server</a>'; if (publicUrl && current.fileId) links += '<a href="' + esc(publicUrl + '/files/' + list.kind + '/' + current.fileId + '/icon.png') + '" target="_blank" rel="noopener">Open on the UGC server</a>';
if (canManage) { if (canManage && current.fileId) {
links += '<div class="mt-2 d-flex flex-wrap gap-1"><button type="button" class="btn btn-sm btn-outline-warning" data-remake-open="' + esc(id) + '">Make again</button>' + links += '<div class="mt-2 d-flex flex-wrap gap-1"><button type="button" class="btn btn-sm btn-outline-warning" data-remake-open="' + esc(current.fileId) + '">Make again</button>' +
'<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="now">Delete files, make again</button>' + '<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="now">Delete files, make again</button>' +
'<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="gone">Delete files, leave deleted</button></div>'; '<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="gone">Delete files, leave deleted</button></div>';
if (item.sharedBy > 1) links += '<div class="text-body-secondary mt-1">This icon is shared by ' + esc(item.sharedBy) + ' builds of the same modules.</div>';
} }
if (item.error) links += '<div class="text-danger mt-2">' + esc(item.error) + '</div>'; if (item.error) links += '<div class="text-danger mt-2">' + esc(item.error) + '</div>';
$('previewLinks').innerHTML = links; $('previewLinks').innerHTML = links;
var model = kind === 'model'; ['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', modular); });
['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); }); $('statsBox').innerHTML = modular ? '<div class="small mb-1">Modules (combination <code>' + esc(item.key) + '</code>, used by ' + esc(item.uses) + ' build' + (item.uses === 1 ? '' : 's') +
$('statsBox').innerHTML = model ? '' : '<div class="small">Modules: <code>' + esc(item.modules || '') + '</code> (combination <code>' + esc(item.combination || '') + '). The game client puts cars and rockets together itself; the UGC server only draws the icon, once per combination.</div>' + modulesHtml(item) : '';
'</code>). The game client puts cars and rockets together itself; only the icon is made, once per combination of modules.</div>'; $('referencesCard').classList.toggle('d-none', !modular);
UgcIconEditor.open(model ? { model: true, id: id } : { model: false, modules: item.modules || '', label: item.combination }); 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(); 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) { Promise.all([fetchJson(fileUrl('model', id, 'stats.json')), fetchJson(fileUrl('model', id, 'previous.stats.json'))]).then(function (s) {
if (!current || current.id !== id) return; if (!current || current.id !== id) return;
@@ -247,12 +337,11 @@
current.statsLoaded = true; current.statsLoaded = true;
$('statsBox').innerHTML = statsTable(s[0], s[1]); $('statsBox').innerHTML = statsTable(s[0], s[1]);
var lods = (s[0] && s[0].lods) || [{ lod: 0 }]; var lods = (s[0] && s[0].lods) || [{ lod: 0 }];
$('lodSelect').innerHTML = lods.map(function (l, i) { return '<option value="' + i + '">LOD ' + esc(l.lod) + (l.far ? ' (' + esc(l.near) + '-' + esc(l.far) + ')' : '') + '</option>'; }).join(''); $('lodSelect').innerHTML = lods.map(function (l, n) { return '<option value="' + n + '">LOD ' + esc(l.lod) + (l.far ? ' (' + esc(l.near) + '-' + esc(l.far) + ')' : '') + '</option>'; }).join('');
$('versionSelect').querySelector('[value=previous]').disabled = !s[1]; $('versionSelect').querySelector('[value=previous]').disabled = !s[1];
$('versionSelect').value = 'current'; $('versionSelect').value = 'current';
loadMesh(true); loadMesh(true);
}); });
var nifBox = $('nifViewer'), lxfmlBox = $('lxfmlViewer'); var nifBox = $('nifViewer'), lxfmlBox = $('lxfmlViewer');
import('/js/ugc-viewer.js').then(function (module) { import('/js/ugc-viewer.js').then(function (module) {
if (!nifViewer) { 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); 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; }); }).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 <a href="/properties/' + esc(x.propertyId) + '">' + esc(x.propertyName || 'property ' + x.propertyId) + '</a>' + (x.ownerName ? ' of ' + esc(x.ownerName) : '');
if (x.type === 'mail') return 'In a mail to <a href="/characters/' + esc(x.characterId) + '">' + esc(x.characterName || x.characterId) + '</a>';
return 'In the creator\'s ' + esc(x.inventory || 'inventory');
}).join('<br>') || '<span class="text-body-secondary">Not found placed, mailed or with its creator</span>';
}
function loadRefs() {
if (!refs) return;
var wanted = refs;
$('refRows').innerHTML = '<tr><td colspan="6" class="text-body-secondary">Loading…</td></tr>';
api.get('/api/ugc/assembly/builds?modules=' + encodeURIComponent(refs.key) + '&q=' + encodeURIComponent(refs.q) + '&page=' + refs.page + '&size=25' +
(refs.highlight ? '&build=' + encodeURIComponent(refs.highlight) : '')).then(function (d) {
if (refs !== wanted) return;
if (!d.success) { $('refRows').innerHTML = '<tr><td colspan="6" class="text-danger">' + esc(d.error || 'Failed') + '</td></tr>'; return; }
refs.page = d.page;
$('refRows').innerHTML = d.items.map(function (b) {
return '<tr' + (b.id === refs.highlight ? ' class="ugc-highlight" id="refHighlight"' : '') + '><td><code>' + esc(b.id) + '</code></td><td>' + owner(b) + '</td><td>' +
(b.accountId ? '<a href="/accounts/' + esc(b.accountId) + '">' + esc(b.accountName || b.accountId) + '</a>' : '') + '</td><td>' + badge(b.state) +
(b.error ? '<div class="text-danger">' + esc(b.error) + '</div>' : '') + '</td><td>' + whereText(b.where) + '</td><td class="text-end">' +
'<a class="btn btn-sm btn-outline-secondary" href="/ugc_search?q=' + encodeURIComponent('id:' + b.id) + '">Find</a></td></tr>';
}).join('') || '<tr><td colspan="6" class="text-body-secondary">No builds match.</td></tr>';
pager($('refPager'), d.page, Math.max(1, Math.ceil(d.total / d.size)), function (p) { refs.page = p; refs.highlight = ''; loadRefs(); });
$('refTotal').textContent = d.total + ' build' + (d.total === 1 ? '' : 's');
var hl = $('refHighlight');
if (hl) hl.scrollIntoView({ block: 'nearest' });
});
}
$('refSearch').addEventListener('input', function () {
var value = this.value.trim();
clearTimeout(refTimer);
refTimer = setTimeout(function () { if (refs) { refs.q = value; refs.page = 0; refs.highlight = ''; loadRefs(); } }, 300);
});
// ---- controls ----
$('kindButtons').addEventListener('click', function (e) { $('kindButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-kind]'); var button = e.target.closest('[data-kind]');
if (button) setKind(button.dataset.kind); if (button && button.dataset.kind !== list.kind) changeList({ kind: button.dataset.kind, page: 0, sort: 'newest', type: '' }, true);
}); });
$('viewButtons').addEventListener('click', function (e) { $('viewButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-view]'); var button = e.target.closest('[data-view]');
if (!button) return; if (!button) return;
view = button.dataset.view; try { localStorage.setItem('ugcView', button.dataset.view); } catch (err) { /* storage blocked */ }
page = 0; changeList({ view: button.dataset.view, page: 0 }, false);
try { localStorage.setItem('ugcView', view); } catch (err) { /* storage blocked */ } });
this.querySelectorAll('[data-view]').forEach(function (b) { b.classList.toggle('active', b === button); }); $('stateFilter').addEventListener('change', function () { changeList({ state: this.value, page: 0 }, true); });
load(); $('typeFilter').addEventListener('change', function () { changeList({ type: this.value, page: 0 }, true); });
$('sortSelect').addEventListener('change', function () { changeList({ sort: this.value, page: 0 }, true); });
$('pageSize').addEventListener('change', function () { Prefs.set('ugc.pageSize.' + list.view, +this.value); changeList({ page: 0 }, false); });
$('jumpPage').addEventListener('change', function () {
var p = Math.max(1, Math.min(+this.max || 1, parseInt(this.value, 10) || 1));
this.value = '';
changeList({ page: p - 1 }, true);
}); });
$('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; var searchTimer = null;
$('search').addEventListener('input', function () { $('search').addEventListener('input', function () {
var value = this.value.trim(); var value = this.value.trim();
clearTimeout(searchTimer); clearTimeout(searchTimer);
searchTimer = setTimeout(function () { search = value; page = 0; load(); }, 300); searchTimer = setTimeout(function () { changeList({ q: value, page: 0 }, false); }, 300);
}); });
$('prevPage').addEventListener('click', function () { if (page > 0) { page--; load(); } });
$('nextPage').addEventListener('click', function () { page++; load(); });
$('retryFailed').addEventListener('click', function () { remake({ failedOnly: true }); }); $('retryFailed').addEventListener('click', function () { remake({ failedOnly: true }); });
$('remakeAll').addEventListener('click', function () { $('remakeAll').addEventListener('click', function () {
remake({}, 'Make every ' + (kind === 'model' ? 'model' : 'car and rocket') + ' again? This can take a long time.'); remake({}, 'Make every ' + (list.kind === 'model' ? 'model' : 'car and rocket') + ' again? This can take a long time.');
}); });
function onItemClick(e) { function onItemClick(e) {
var remakeButton = e.target.closest('[data-remake]'), previewButton = e.target.closest('[data-preview]'); var remakeButton = e.target.closest('[data-remake]'), previewButton = e.target.closest('[data-preview]');
if (remakeButton) remake({ id: remakeButton.dataset.remake }); if (remakeButton) remake({ id: remakeButton.dataset.remake });
else if (previewButton) preview(previewButton.dataset.preview); else if (previewButton) {
var item = items.find(function (i) { return i.id === previewButton.dataset.preview; });
if (item) preview(item);
}
} }
$('rows').addEventListener('click', onItemClick); $('rows').addEventListener('click', onItemClick);
$('gallery').addEventListener('click', onItemClick); $('gallery').addEventListener('click', onItemClick);
@@ -318,20 +458,22 @@
$('reframeButton').addEventListener('click', function () { if (nifViewer) nifViewer.frame(); }); $('reframeButton').addEventListener('click', function () { if (nifViewer) nifViewer.frame(); });
$('previewModal').addEventListener('hidden.bs.modal', function () { $('previewModal').addEventListener('hidden.bs.modal', function () {
current = null; current = null;
refs = null;
UgcIconEditor.close(); UgcIconEditor.close();
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose(); if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlViewer = null; lxfmlViewer = null;
// Closed here rather than with Back: take the item out of the address too if (readUrlItem()) writeUrl(true);
if (urlItem()) {
if (pushedItem) history.back();
else history.replaceState(null, '', location.pathname);
}
pushedItem = false;
}); });
window.addEventListener('popstate', function () { window.addEventListener('popstate', function () {
pushedItem = false; var item = readUrl();
openFromUrl(); load();
if (!item) {
if (current) bootstrap.Modal.getOrCreateInstance($('previewModal')).hide();
} else if (!current || current.id !== item.id) {
openItem(item.kind, item.id, item.build);
}
}); });
// ---- deleting and purging stored files ---- // ---- deleting and purging stored files ----
function showDeleteResult(box, d) { function showDeleteResult(box, d) {
@@ -347,19 +489,19 @@
if (!button || !current) return; if (!button || !current) return;
var after = button.dataset.deleteOpen; 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; 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); }); api.post('/api/ugc/cache/delete', { kind: current.kind, id: current.fileId, after: after }).then(function (d) { showDeleteResult(null, d); });
}); });
function purge(all) { function purge(all) {
var body = { kind: kind, state: $('purgeState').value, owner: $('purgeOwner').value.trim(), olderThanDays: +$('purgeOlder').value || 0, var body = { kind: list.kind, state: $('purgeState').value, owner: $('purgeOwner').value.trim(), olderThanDays: +$('purgeOlder').value || 0,
unusedDays: +$('purgeUnused').value || 0, after: $('purgeAfter').value }; unusedDays: +$('purgeUnused').value || 0, after: $('purgeAfter').value };
if (all) { if (all) {
var typed = prompt('This deletes every stored ' + (kind === 'model' ? 'model' : 'car and rocket') + ' file. Type PURGE ALL to go ahead.'); var typed = prompt('This deletes every stored ' + (list.kind === 'model' ? 'model' : 'car and rocket') + ' file. Type PURGE ALL to go ahead.');
if (typed !== 'PURGE ALL') return; if (typed !== 'PURGE ALL') return;
body = { kind: kind, all: true, confirm: typed, after: $('purgeAfter').value }; body = { kind: list.kind, all: true, confirm: typed, after: $('purgeAfter').value };
} else if (!body.state && !body.owner && !body.olderThanDays && !body.unusedDays) { } else if (!body.state && !body.owner && !body.olderThanDays && !body.unusedDays) {
toast('Pick a filter first, or purge all', 'warning'); toast('Pick a filter first, or purge all', 'warning');
return; return;
} else if (!confirm('Delete the stored files of every ' + (kind === 'model' ? 'model' : 'car and rocket') + ' matching the filter?')) { } else if (!confirm('Delete the stored files of every ' + (list.kind === 'model' ? 'model' : 'car and rocket') + ' matching the filter?')) {
return; return;
} }
$('purgeResult').textContent = 'Deleting…'; $('purgeResult').textContent = 'Deleting…';
@@ -381,13 +523,7 @@
var button = e.target.closest('[data-preset]'); var button = e.target.closest('[data-preset]');
var entry = button && kinds.find(function (k) { return k.kind === button.dataset.preset; }); var entry = button && kinds.find(function (k) { return k.kind === button.dataset.preset; });
if (!entry || !entry.sample) return; if (!entry || !entry.sample) return;
if (entry.kind === 'model') { openItem(entry.kind === 'model' ? 'model' : 'modular', String(entry.sample));
setKind('model');
preview(String(entry.sample));
} else {
setKind('modular');
preview(entry.sample, { id: entry.sample, modules: entry.sample.replace(/-/g, '+'), combination: entry.sample, state: 'done', sharedBy: entry.sampleBuilds });
}
}; };
}).catch(function (e) { $('presetsList').textContent = 'Could not load the icon types: ' + e.message; }); }).catch(function (e) { $('presetsList').textContent = 'Could not load the icon types: ' + e.message; });
} }
@@ -395,11 +531,9 @@
if (window.Live) Live.on('ugc', load); if (window.Live) Live.on('ugc', load);
var linked = urlItem(); var linked = readUrl();
if (linked) kind = linked.kind; writeUrl(false, linked);
$('kindButtons').querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b.dataset.kind === kind); }); load().then(function () { if (linked) openItem(linked.kind, linked.id, linked.build); });
load();
if (linked) openFromUrl();
loadStatus(); loadStatus();
setInterval(loadStatus, 10000); setInterval(loadStatus, 10000);
})(); })();

View File

@@ -49,10 +49,13 @@
<button type="button" class="btn btn-outline-primary active" data-kind="model">Models</button> <button type="button" class="btn btn-outline-primary active" data-kind="model">Models</button>
<button type="button" class="btn btn-outline-primary" data-kind="modular">Cars and rockets</button> <button type="button" class="btn btn-outline-primary" data-kind="modular">Cars and rockets</button>
</div> </div>
<input type="search" class="form-control form-control-sm" style="max-width: 22rem" id="search" placeholder="Search: text, owner:, account:, property:, name:, lot:, id:, state:, kind:"
aria-label="Search" title="Plain text matches names, owners and ids. Prefixes search one field: owner:, account:, property:, name:, lot: or module:, id:, state: (pending, done, failed, empty), kind: (a car or rocket type)">
<select class="form-select form-select-sm w-auto" id="stateFilter" aria-label="State"> <select class="form-select form-select-sm w-auto" id="stateFilter" aria-label="State">
<option value="">Every state</option><option value="pending">Waiting</option><option value="done">Made</option><option value="failed">Failed</option><option value="empty">Empty (no bricks)</option> <option value="">Every state</option><option value="pending">Waiting</option><option value="done">Made</option><option value="failed">Failed</option><option value="empty">Empty (no bricks)</option>
</select> </select>
<input type="search" class="form-control form-control-sm w-auto" id="search" placeholder="ID or owner" aria-label="Search by ID or owner"> <select class="form-select form-select-sm w-auto d-none" id="typeFilter" aria-label="Type"><option value="">Every type</option></select>
<select class="form-select form-select-sm w-auto" id="sortSelect" aria-label="Sort"></select>
<div class="btn-group btn-group-sm" role="group" id="viewButtons"> <div class="btn-group btn-group-sm" role="group" id="viewButtons">
<button type="button" class="btn btn-outline-secondary active" data-view="gallery">Icons</button> <button type="button" class="btn btn-outline-secondary active" data-view="gallery">Icons</button>
<button type="button" class="btn btn-outline-secondary" data-view="list">List</button> <button type="button" class="btn btn-outline-secondary" data-view="list">List</button>
@@ -66,13 +69,16 @@
<div id="gallery" class="ugc-gallery mb-2"></div> <div id="gallery" class="ugc-gallery mb-2"></div>
<div class="card d-none" id="listCard"> <div class="card d-none" id="listCard">
<table class="table table-sm align-middle mb-0 table-stack"> <table class="table table-sm align-middle mb-0 table-stack">
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Last attempt</th><th>Details</th><th></th></tr></thead> <thead id="listHead"></thead>
<tbody id="rows"></tbody> <tbody id="rows"></tbody>
</table> </table>
</div> </div>
<div class="d-flex gap-2 mt-2"> <div class="d-flex flex-wrap gap-2 align-items-center mt-2" id="pagerBar">
<button type="button" class="btn btn-sm btn-outline-secondary" id="prevPage">Newer</button> <nav aria-label="Pages"><ul class="pagination pagination-sm mb-0" id="pager"></ul></nav>
<button type="button" class="btn btn-sm btn-outline-secondary" id="nextPage">Older</button> <label class="d-flex align-items-center gap-1 small">Page <input type="number" min="1" class="form-control form-control-sm" style="width:5rem" id="jumpPage" aria-label="Go to page"></label>
<label class="d-flex align-items-center gap-1 small">Show <select class="form-select form-select-sm w-auto" id="pageSize" aria-label="Per page">
<option>24</option><option>48</option><option>96</option><option>200</option></select> per page</label>
<span class="small text-body-secondary ms-auto" id="totalText"></span>
</div> </div>
<div class="modal fade" id="previewModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="previewModal" tabindex="-1" aria-hidden="true">
@@ -156,6 +162,15 @@
<div class="row g-2 mt-1" id="framingControls"></div> <div class="row g-2 mt-1" id="framingControls"></div>
</div> </div>
</div> </div>
<div class="card mt-3 d-none" id="referencesCard">
<div class="card-header py-2 small fw-semibold d-flex flex-wrap align-items-center gap-2">References <span class="text-body-secondary fw-normal">the builds that use these modules</span>
<input type="search" class="form-control form-control-sm ms-auto" style="max-width: 16rem" id="refSearch" placeholder="Owner, account, property or id" aria-label="Search the builds"></div>
<div class="table-responsive"><table class="table table-sm small align-middle mb-0">
<thead><tr><th>Build (blueprint id)</th><th>Owner</th><th>Account</th><th>State</th><th>Where it is</th><th></th></tr></thead>
<tbody id="refRows"></tbody></table></div>
<div class="card-footer py-1 d-flex align-items-center gap-2 small"><nav aria-label="Reference pages"><ul class="pagination pagination-sm mb-0" id="refPager"></ul></nav>
<span class="ms-auto text-body-secondary" id="refTotal"></span></div>
</div>
</div> </div>
</div></div> </div></div>
</div> </div>
@@ -170,6 +185,8 @@
linear-gradient(45deg, rgba(128,128,128,.15) 25%, transparent 25%, transparent 75%, rgba(128,128,128,.15) 75%); linear-gradient(45deg, rgba(128,128,128,.15) 25%, transparent 25%, transparent 75%, rgba(128,128,128,.15) 75%);
background-size: 16px 16px; background-position: 0 0, 8px 8px; } background-size: 16px 16px; background-position: 0 0, 8px 8px; }
.ugc-meter { height: .4rem; } .ugc-meter { height: .4rem; }
.ugc-modules img { width: 32px; height: 32px; }
tr.ugc-highlight > td { background-color: var(--bs-warning-bg-subtle); }
</style> </style>
{% endblock %} {% endblock %}