From 56c3a81db258e278c6c8bc3da8fe02774410e2a1 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 20:38:38 -0500 Subject: [PATCH] fix(dashboard): character inventory loads fast with thousands of items The inventory modal drew every item of every tab into the page, and one item details request for all of them (over the 500 a request allows, so no tooltips at all). Now the items come from the summary API when the modal opens and are drawn one tab at a time, 240 at a time as the tab scrolls, with a filter by name or LOT. Each chunk's item details are fetched with it (at most 500 a request) and a tooltip is made on first hover. Player-built items get their UGC icon as their chunk is drawn (UgcLinks.inventoryLinks and decorateItem). Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/static/js/ugc-links.js | 69 +++++--- .../templates/character-view.jinja2 | 166 +++++++++++++++--- 2 files changed, 183 insertions(+), 52 deletions(-) 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 %} -
- +
+
Loading…
{% endfor %}
@@ -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('' + lot + ''); + } + 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');