mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () {});
|
||||
}
|
||||
|
||||
@@ -267,20 +267,11 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<input type="search" class="form-control form-control-sm mt-2" id="inventoryFilter" placeholder="Filter by name or LOT" aria-label="Filter the items">
|
||||
<div class="tab-content mt-3">
|
||||
{% for inv in character.stats.inventories %}
|
||||
<div class="tab-pane fade{% if loop.is_first %}{{ " show active" }}{% endif %}" id="inv-{{ inv.type_id }}">
|
||||
<div class="d-flex flex-wrap">
|
||||
{% for item in inv.items %}
|
||||
{% if can.reports_view and item.id != "" %}<a href="/reports?object={{ item.id }}" class="text-reset text-decoration-none" aria-label="Trace {{ item.name }}">{% endif %}
|
||||
<div class="inv-item{% if item.equipped %}{{ " equipped" }}{% endif %}" data-id="{{ item.id }}" data-lot="{{ item.lot }}" data-count="{{ item.count }}" data-equipped="{% if item.equipped %}1{% endif %}" data-bound="{% if item.bound %}1{% endif %}" title="{{ item.name }}">
|
||||
<img src="/api/icon/{{ item.lot }}" alt="{{ item.name }}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2264%22 height=%2264%22><rect fill=%22%23333%22 width=%2264%22 height=%2264%22/><text x=%2232%22 y=%2236%22 fill=%22%23888%22 text-anchor=%22middle%22 font-size=%2210%22>{{ item.lot }}</text></svg>'">
|
||||
{% if item.count > 1 %}<span class="inv-count">{% if item.count > 999 %}999+{% else %}{{ item.count }}{% endif %}</span>{% endif %}
|
||||
{% if item.bound %}<span class="inv-lock">L</span>{% endif %}
|
||||
</div>
|
||||
{% if can.reports_view and item.id != "" %}</a>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="tab-pane fade{% if loop.is_first %}{{ " show active" }}{% endif %}" id="inv-{{ inv.type_id }}" data-inventory="{{ inv.type_id }}">
|
||||
<div class="d-flex flex-wrap" data-items><span class="text-body-secondary small">Loading…</span></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -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('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect fill="#333" width="64" height="64"/><text x="32" y="36" fill="#888" text-anchor="middle" font-size="10">' + lot + '</text></svg>');
|
||||
}
|
||||
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 = '<span class="text-body-secondary small">' + (filter ? 'Nothing matches.' : 'Empty.') + '</span>';
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user