diff --git a/dDashboardServer/static/js/ugc-links.js b/dDashboardServer/static/js/ugc-links.js
index 6ff0013fd..16f550cd4 100644
--- a/dDashboardServer/static/js/ugc-links.js
+++ b/dDashboardServer/static/js/ugc-links.js
@@ -75,6 +75,8 @@
UgcLinks.details(box.dataset.ugcDetails, box.dataset.via, box.dataset.model).then(function (html) { body.innerHTML = html; });
}, true);
+ var linkCache = {}; // character id -> promise of its item links
+
window.UgcLinks = {
// A badge for the UGC server's state; a failure's reason as its tooltip
state: function (item) {
@@ -108,32 +110,49 @@
}).catch(function () { return {}; });
},
// Shows the creations among a character page's items (.inv-item[data-id]) with their own icon and a link
+ // Itemid -> the UGC link of each of a character's player-built items (fetched once per character)
+ inventoryLinks: function (characterId) {
+ if (!linkCache[characterId]) {
+ linkCache[characterId] = api.get('/api/ugc_links/character/' + characterId).then(function (d) {
+ var map = {};
+ ((d && d.items) || []).forEach(function (item) { map[item.itemId] = item; });
+ return map;
+ });
+ }
+ return linkCache[characterId];
+ },
+
+ // Shows a player-built item's UGC icon and a link to the UGC viewer on one inventory item element
+ decorateItem: function (el, item) {
+ var img = el.querySelector('img');
+ if (img) {
+ var own = img.src;
+ img.onerror = function () { img.onerror = null; img.src = own; };
+ img.src = item.icon;
+ }
+ el.classList.add('inv-ugc');
+ el.title = (el.title ? el.title + ' - ' : '') + 'Player-built ' + (item.kind === 'modular' ? 'car or rocket' : 'model') + ' (UGC ' + item.ugcId + ', ' + item.state + ')';
+ if (item.link && !el.querySelector('.inv-ugc-link')) {
+ // Items may sit inside a link already (the item trace), so this is a button, not a nested link
+ var a = document.createElement('span');
+ a.className = 'inv-ugc-link';
+ a.setAttribute('role', 'link');
+ a.tabIndex = 0;
+ a.textContent = 'UGC';
+ a.title = 'Open in the UGC viewer';
+ var open = function (e) { e.preventDefault(); e.stopPropagation(); window.location.href = item.link; };
+ a.addEventListener('click', open);
+ a.addEventListener('keydown', function (e) { if (e.key === 'Enter') open(e); });
+ el.appendChild(a);
+ }
+ },
+
+ // Decorates the inventory items already on the page
decorateInventory: function (characterId, root) {
- return api.get('/api/ugc_links/character/' + characterId).then(function (d) {
- ((d && d.items) || []).forEach(function (item) {
- (root || document).querySelectorAll('.inv-item[data-id="' + item.itemId + '"]').forEach(function (el) {
- var img = el.querySelector('img');
- if (img) {
- var own = img.src;
- img.onerror = function () { img.onerror = null; img.src = own; };
- img.src = item.icon;
- }
- el.classList.add('inv-ugc');
- el.title = (el.title ? el.title + ' - ' : '') + 'Player-built ' + (item.kind === 'modular' ? 'car or rocket' : 'model') + ' (UGC ' + item.ugcId + ', ' + item.state + ')';
- if (item.link && !el.querySelector('.inv-ugc-link')) {
- // Items may sit inside a link already (the item trace), so this is a button, not a nested link
- var a = document.createElement('span');
- a.className = 'inv-ugc-link';
- a.setAttribute('role', 'link');
- a.tabIndex = 0;
- a.textContent = 'UGC';
- a.title = 'Open in the UGC viewer';
- var open = function (e) { e.preventDefault(); e.stopPropagation(); window.location.href = item.link; };
- a.addEventListener('click', open);
- a.addEventListener('keydown', function (e) { if (e.key === 'Enter') open(e); });
- el.appendChild(a);
- }
- });
+ var self = this;
+ return this.inventoryLinks(characterId).then(function (map) {
+ Object.keys(map).forEach(function (id) {
+ (root || document).querySelectorAll('.inv-item[data-id="' + id + '"]').forEach(function (el) { self.decorateItem(el, map[id]); });
});
}).catch(function () {});
}
diff --git a/dDashboardServer/templates/character-view.jinja2 b/dDashboardServer/templates/character-view.jinja2
index a825406aa..ee030df86 100644
--- a/dDashboardServer/templates/character-view.jinja2
+++ b/dDashboardServer/templates/character-view.jinja2
@@ -267,20 +267,11 @@
{% endfor %}
+
{% for inv in character.stats.inventories %}
-
@@ -492,9 +483,7 @@ document.addEventListener('DOMContentLoaded', function() {
el.textContent = ts > 0 ? new Date(ts * 1000).toLocaleString() : 'Never';
}
var inventory = document.getElementById('inventoryModal');
- if (inventory) inventory.addEventListener('show.bs.modal', loadItemTooltips, { once: true });
- // Models, cars and rockets the player built show the UGC server's icon of them
- if (inventory) inventory.addEventListener('show.bs.modal', function () { UgcLinks.decorateInventory(characterId, inventory); }, { once: true });
+ if (inventory) inventory.addEventListener('show.bs.modal', InventoryView.open, { once: true });
});
var characterId = document.getElementById('restrictions').getAttribute('data-character-id');
@@ -548,19 +537,142 @@ function tooltipHtml(info, el) {
return html;
}
-function loadItemTooltips() {
- var items = Array.prototype.slice.call(document.querySelectorAll('.inv-item[data-lot]'));
- var lots = Array.from(new Set(items.map(function (el) { return parseInt(el.getAttribute('data-lot'), 10); })));
- if (!lots.length) return;
- api.post('/api/items/info', { lots: lots }).then(function (info) {
- items.forEach(function (el) {
- var item = info[el.getAttribute('data-lot')];
- if (!item) return;
- el.removeAttribute('title');
- new bootstrap.Tooltip(el, { html: true, title: tooltipHtml(item, el), placement: 'right', fallbackPlacements: ['left', 'bottom', 'top'], customClass: 'item-tooltip' });
- });
+/**
+ * The inventory modal. Characters can own thousands of kinds of item (every brick), so the items are drawn from the
+ * summary API one tab at a time and in chunks as the tab scrolls, with a filter; each chunk's item details (for the
+ * tooltips, at most 500 per request) and UGC icons are fetched as it's drawn, and a tooltip is made on first hover.
+ */
+var InventoryView = (function () {
+ var CHUNK = 240;
+ var inventories = {}, shown = {}, info = {}, ugcLinks = null, filter = '';
+ var canTrace = DASH.can('reports_view');
+ var modal = document.getElementById('inventoryModal');
+
+ function fallbackIcon(lot) {
+ return 'data:image/svg+xml,' + encodeURIComponent('
');
+ }
+ function itemEl(item) {
+ var div = document.createElement('div');
+ div.className = 'inv-item' + (item.equipped ? ' equipped' : '');
+ div.dataset.id = item.id || '';
+ div.dataset.lot = item.lot;
+ div.dataset.count = item.count;
+ if (item.equipped) div.dataset.equipped = '1';
+ if (item.bound) div.dataset.bound = '1';
+ div.title = item.name || ('LOT ' + item.lot);
+ var img = document.createElement('img');
+ img.loading = 'lazy';
+ img.alt = item.name || '';
+ img.src = '/api/icon/' + item.lot;
+ img.onerror = function () { img.onerror = null; img.src = fallbackIcon(item.lot); };
+ div.appendChild(img);
+ if (item.count > 1) { var c = document.createElement('span'); c.className = 'inv-count'; c.textContent = item.count > 999 ? '999+' : item.count; div.appendChild(c); }
+ if (item.bound) { var l = document.createElement('span'); l.className = 'inv-lock'; l.textContent = 'L'; div.appendChild(l); }
+ if (ugcLinks && item.id && ugcLinks[item.id]) UgcLinks.decorateItem(div, ugcLinks[item.id]);
+ if (!canTrace || !item.id) return div;
+ var a = document.createElement('a');
+ a.href = '/reports?object=' + encodeURIComponent(item.id);
+ a.className = 'text-reset text-decoration-none';
+ a.setAttribute('aria-label', 'Trace ' + (item.name || item.lot));
+ a.appendChild(div);
+ return a;
+ }
+ function matches(item) {
+ return !filter || String(item.lot).indexOf(filter) === 0 || (item.name || '').toLowerCase().indexOf(filter) !== -1;
+ }
+ // Item details for the tooltips of the lots just drawn, at most 500 a request
+ function loadInfo(items) {
+ var lots = Array.from(new Set(items.map(function (i) { return i.lot; }).filter(function (lot) { return !(lot in info); })));
+ for (var i = 0; i < lots.length; i += 500) {
+ var batch = lots.slice(i, i + 500);
+ batch.forEach(function (lot) { info[lot] = null; });
+ api.post('/api/items/info', { lots: batch }).then(function (d) {
+ Object.keys(d || {}).forEach(function (lot) { info[lot] = d[lot]; });
+ }).catch(function () {});
+ }
+ }
+ // Draws the next chunk of a tab; a sentinel at the end draws the one after when it scrolls into view
+ function drawMore(type) {
+ var pane = document.getElementById('inv-' + type);
+ var box = pane && pane.querySelector('[data-items]');
+ if (!box) return;
+ var state = shown[type];
+ var chunk = state.items.slice(state.count, state.count + CHUNK);
+ var old = box.querySelector('.inv-more');
+ if (old) old.remove();
+ var frag = document.createDocumentFragment();
+ chunk.forEach(function (item) { frag.appendChild(itemEl(item)); });
+ box.appendChild(frag);
+ state.count += chunk.length;
+ loadInfo(chunk);
+ if (!state.items.length) box.innerHTML = '
' + (filter ? 'Nothing matches.' : 'Empty.') + '';
+ if (state.count < state.items.length) {
+ var more = document.createElement('div');
+ more.className = 'inv-more w-100 text-center small text-body-secondary py-2';
+ more.textContent = (state.items.length - state.count).toLocaleString() + ' moreā¦';
+ box.appendChild(more);
+ var observer = new IntersectionObserver(function (entries) {
+ if (!entries.some(function (e) { return e.isIntersecting; })) return;
+ observer.disconnect();
+ drawMore(type);
+ }, { root: modal.querySelector('.modal-body') });
+ observer.observe(more);
+ }
+ }
+ function drawTab(type) {
+ var inv = inventories[type];
+ var box = document.querySelector('#inv-' + type + ' [data-items]');
+ if (!inv || !box) return;
+ box.innerHTML = '';
+ shown[type] = { items: inv.items.filter(matches), count: 0 };
+ drawMore(type);
+ }
+ function activeType() {
+ var pane = modal.querySelector('.tab-pane.active');
+ return pane ? pane.dataset.inventory : null;
+ }
+
+ // Tooltips: made when an item is first hovered, from the details loaded with its chunk
+ modal.addEventListener('mouseover', function (e) {
+ var el = e.target.closest('.inv-item[data-lot]');
+ if (!el || el.dataset.tip) return;
+ var item = info[el.dataset.lot];
+ if (!item) return;
+ el.dataset.tip = '1';
+ el.removeAttribute('title');
+ bootstrap.Tooltip.getOrCreateInstance(el, { html: true, title: tooltipHtml(item, el), placement: 'right', fallbackPlacements: ['left', 'bottom', 'top'], customClass: 'item-tooltip' }).show();
});
-}
+ modal.addEventListener('shown.bs.tab', function (e) {
+ var type = (e.target.dataset.bsTarget || '').replace('#inv-', '');
+ if (inventories[type] && !shown[type]) drawTab(type);
+ });
+ var filterTimer = null;
+ var filterInput = document.getElementById('inventoryFilter');
+ if (filterInput) filterInput.addEventListener('input', function () {
+ clearTimeout(filterTimer);
+ filterTimer = setTimeout(function () {
+ filter = filterInput.value.trim().toLowerCase();
+ shown = {};
+ var type = activeType();
+ if (type) drawTab(type);
+ }, 200);
+ });
+
+ return {
+ open: function () {
+ // Models, cars and rockets the player built show the UGC server's icon of them
+ var links = UgcLinks.inventoryLinks(characterId).then(function (map) { ugcLinks = map; }).catch(function () { ugcLinks = {}; });
+ var summary = api.get('/api/characters/' + characterId + '/summary');
+ Promise.all([summary, links]).then(function (r) {
+ var d = r[0];
+ if (!d || !d.success) { modal.querySelectorAll('[data-items]').forEach(function (b) { b.textContent = (d && d.error) || 'Could not load the inventory'; }); return; }
+ (d.summary.inventories || []).forEach(function (inv) { inventories[inv.type] = inv; });
+ var type = activeType();
+ if (type) drawTab(type);
+ }).catch(function () {});
+ }
+ };
+})();
function openMailbox() {
var list = document.getElementById('mailList');