From fbf923e043a33fdbf5849bd838d2475b26293a7f Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 15:46:47 -0500 Subject: [PATCH] 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 --- dDashboardServer/static/js/ugc.js | 430 +++++++++++++++++--------- dDashboardServer/templates/ugc.jinja2 | 27 +- 2 files changed, 304 insertions(+), 153 deletions(-) diff --git a/dDashboardServer/static/js/ugc.js b/dDashboardServer/static/js/ugc.js index e73657edc..edddabf8a 100644 --- a/dDashboardServer/static/js/ugc.js +++ b/dDashboardServer/static/js/ugc.js @@ -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 - * (from the database), its live status, and a viewer comparing a model's generated mesh, its LXFML and its icon, now - * and before it was made again. Everything from the UGC server comes through the dashboard (/api/ugc/server/status, - * /api/ugc/files/..., /api/ugc/mesh/...), so the browser never has to reach the UGC server itself. + * The UGC Server page: counts, the UGC server's live status, and what it made: player models, and cars and rockets as + * assemblies (one per combination of modules, however many builds use it), paged on the server with a search box + * (field prefixes), filters and sorting, all kept in the address so Back and links work. An item opens with its + * 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 () { '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 PAGE_SIZE = { gallery: 48, list: 50 }; - - try { view = localStorage.getItem('ugcView') === 'list' ? 'list' : 'gallery'; } catch (e) { /* storage blocked */ } + var SORTS = { + model: [['newest', 'Newest'], ['oldest', 'Oldest'], ['bricks', 'Most bricks'], ['triangles', 'Most triangles'], ['owner', 'Owner'], ['name', 'File name']], + 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 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) { - return '/api/ugc/files/' + itemKind + '/' + encodeURIComponent(id) + '/' + name; + // ---- the address ---- + + 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 ''; }).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) { return '
' + esc(title) + '
' + 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') : '') + '
'; } - function owner(i) { return i.characterName ? '' + esc(i.characterName) + '' : '' + esc(i.characterId) + ''; } - + 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) { if (i.state !== 'done') return '' + esc((STATES[i.state] || [i.state])[0]) + ''; - return ''; + return ''; + } + 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 ? ' after ' + esc(fmt.unix(i.processAfter)) + '' : ''; } - function tile(i) { - var s = STATES[i.state] || [i.state, 'secondary']; - return '
' + iconImg(i, 128) + - '
' + (i.characterName ? esc(i.characterName) : '' + esc(i.characterId) + '') + '
' + - '
' + esc(i.id) + '
' + fmt.badge(s[0], s[1]) + extraBadges(i) + '
'; + var title = list.kind === 'modular' ? esc(i.kindLabel || 'Build type ' + i.buildType) : (i.characterName ? esc(i.characterName) : '' + esc(i.characterId) + ''); + var sub = list.kind === 'modular' ? esc(moduleNames(i)) : esc(i.id); + var extra = list.kind === 'modular' ? ' ' + fmt.badge(i.uses + ' build' + (i.uses === 1 ? '' : 's'), 'info') : waitBadge(i); + return '
' + iconImg(i, 128) + + '
' + title + '
' + sub + '
' + badge(i.state) + extra + '
'; } - - // Cars and rockets: how many builds share this icon; waiting models: when their quiet period after a save ends - function extraBadges(i) { - var out = ''; - if (i.sharedBy > 1) out += ' ' + fmt.badge('shared by ' + i.sharedBy, 'info'); - if (i.state === 'pending' && i.processAfter > Date.now() / 1000) out += ' after ' + esc(fmt.unix(i.processAfter)) + ''; - return out; + function listHead() { + return list.kind === 'modular' ? 'IconTypeModulesStateBuildsOwners' + : 'IconIDOwnerStateMadeSizeDetails'; } - function row(i) { - var s = STATES[i.state] || [i.state, 'secondary']; - var details = (kind === 'modular' ? '' + esc(i.modules) + '' : '') + extraBadges(i); - if (i.error) details += '
' + esc(i.error) + '
'; - var buttons = '' + - (canManage ? '' : ''); - return '' + iconImg(i, 48) + '' + esc(i.id) + '' + owner(i) + '' + fmt.badge(s[0], s[1]) + - (i.attempts ? ' ' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '' : '') + '' + - '' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '' + details + '' + buttons + ''; + var view = ''; + var error = i.error ? '
' + esc(i.error) + '
' : ''; + if (list.kind === 'modular') { + return '' + iconImg(i, 48) + '' + esc(i.kindLabel || i.buildType) + '' + esc(moduleNames(i)) + '
' + esc(i.key) + '
' + error + + '' + badge(i.state) + '' + esc(i.uses) + '' + esc(i.owners) + '' + view + + (canManage ? '' : '') + ''; + } + return '' + iconImg(i, 48) + '' + esc(i.id) + '' + owner(i) + '' + badge(i.state) + + (i.attempts ? ' ' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '' : '') + waitBadge(i) + '' + + '' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '' + (i.bricks ? esc(i.bricks) + ' bricks
' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + + '' + esc(i.detail || '') + error + '' + view + + (canManage ? '' : '') + ''; } - 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 '
  • ' + label + '
  • '; + }; + var html = li(0, '«', page === 0) + li(Math.max(0, page - 1), '‹', page === 0); + if (shown[0] > 0) html += li(0, '1') + (shown[0] > 1 ? '
  • …
  • ' : ''); + 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(pages - 1, pages); + html += li(Math.min(pages - 1, page + 1), '›', page >= pages - 1) + li(pages - 1, '»', 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() { - var size = PAGE_SIZE[view]; - api.get('/api/ugc?kind=' + kind + '&state=' + state + '&page=' + page + '&size=' + size + '&search=' + encodeURIComponent(search)).then(function (d) { - if (!d.success) return; + var size = pageSize(), sequence = ++loadSequence; + showControls(); + 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; + total = d.total; canManage = d.canManage; publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, ''); + if (d.kinds && !kinds.length) { + kinds = d.kinds; + $('typeFilter').innerHTML = '' + kinds.filter(function (k) { return k.buildType !== undefined; }) + .map(function (k) { return ''; }).join(''); + $('typeFilter').value = list.type; + } $('manageButtons').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); - $('gallery').classList.toggle('d-none', view !== 'gallery'); - $('listCard').classList.toggle('d-none', view !== 'list'); - if (view === 'gallery') $('gallery').innerHTML = d.items.map(tile).join('') || '
    Nothing here.
    '; - else $('rows').innerHTML = d.items.map(row).join('') || 'Nothing here.'; - $('prevPage').disabled = page === 0; - $('nextPage').disabled = !d.more; + $('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular); + $('gallery').classList.toggle('d-none', list.view !== 'gallery'); + $('listCard').classList.toggle('d-none', list.view !== 'list'); + var nothing = list.q || list.state || list.type ? 'Nothing matches.' : 'Nothing here.'; + if (list.view === 'gallery') $('gallery').innerHTML = items.map(tile).join('') || '
    ' + nothing + '
    '; + else { + $('listHead').innerHTML = listHead(); + $('rows').innerHTML = items.map(row).join('') || '' + nothing + ''; + } + 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 () {}); } + function changeList(changes, push) { + for (var k in changes) list[k] = changes[k]; + writeUrl(push); + load(); + } + function meter(label, value, limit, text) { var share = limit > 0 ? Math.min(100, value / limit * 100) : 0; var colour = share > 90 ? 'danger' : share > 70 ? 'warning' : 'success'; @@ -119,7 +219,7 @@ function remake(body, question) { if (question && !confirm(question)) return; - body.kind = kind; + body.kind = list.kind; api.post('/api/ugc/reprocess', body).then(function (d) { if (!d.success) { toast(d.error || 'Failed', 'danger'); return; } toast(d.message, 'success'); @@ -127,11 +227,7 @@ }); } - // ---- the viewer ---- - - function fetchJson(url) { - return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); - } + // ---- the open item ---- function statsTable(now, before) { if (!now) return '
    No stats for this model (made before the UGC server wrote them).
    '; @@ -168,78 +264,72 @@ }).catch(function (e) { stats.textContent = 'Could not load the mesh: ' + e.message; }); } - // ---- the open item in the address bar (?item=&kind=model|modular), so it can be shared and Back closes it ---- - - var pushedItem = false; - - function urlItem() { - var params = new URLSearchParams(location.search), id = params.get('item'); - if (!id || !/^\d{1,20}$/.test(id)) return null; - return { id: id, kind: params.get('kind') === 'modular' ? 'modular' : 'model' }; - } - - 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; + function openItem(itemKind, id, build) { + if (list.kind !== itemKind) changeList({ kind: itemKind, page: 0, sort: 'newest', type: '' }, true); + var known = items.find(function (i) { return i.id === id; }); + if (known) return preview(known, build); + if (itemKind === 'modular' && /^\d+$/.test(id) && id.length > 12) { + // A build's id (links from elsewhere): open the assembly it uses, with the build shown in References + return api.get('/api/ugc/assembly/of/' + id).then(function (d) { + if (!d.success) { toast(d.error || 'No such build', 'warning'); return; } + findAndOpen('modular', d.key, id); + }); } - setKind(wanted.kind); - if (current && current.id === wanted.id && current.kind === wanted.kind) return; - var known = items.find(function (i) { return i.id === wanted.id; }); - if (known) return preview(wanted.id, known); - // Not on the page of the list that is showing: look it up by its id - api.get('/api/ugc?kind=' + wanted.kind + '&search=' + wanted.id + '&size=10').then(function (d) { + findAndOpen(itemKind, id, build); + } + // Not on the page showing: looked up by its id (a model's) or its modules (an assembly's) + function findAndOpen(itemKind, id, build) { + var q = itemKind === 'modular' ? 'lot:' + id.split('-')[0] : 'id:' + id; + api.get('/api/ugc?kind=' + itemKind + '&q=' + encodeURIComponent(q) + '&size=200').then(function (d) { canManage = d.canManage; - publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, ''); - preview(wanted.id, (d.items || []).find(function (i) { return i.id === wanted.id; })); - }).catch(function () { preview(wanted.id); }); + var found = (d.items || []).find(function (i) { return i.id === 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 () {}); } - function preview(id, found) { - var item = found || items.find(function (i) { return i.id === id; }) || { id: id, state: 'done' }; - current = { id: id, kind: kind, stats: null }; - var shown = urlItem(); - if (!shown || shown.id !== id || shown.kind !== kind) { - history.pushState(null, '', location.pathname + '?item=' + encodeURIComponent(id) + '&kind=' + kind); - pushedItem = true; - } - $('previewTitle').textContent = (kind === 'model' ? 'Model ' : 'Car or rocket ') + id + (item.characterName ? ' by ' + item.characterName : ''); - $('previewIcon').src = fileUrl(kind, id, 'icon.png'); + function modulesHtml(i) { + return '
    ' + (i.moduleList || []).map(function (m) { + return '
    ' + + '
    ' + esc(m.name || 'LOT ' + m.lot) + '
    ' + (DASH.can('dev_cdclient') ? 'LOT ' + esc(m.lot) + '' : 'LOT ' + esc(m.lot)) + '
    '; + }).join('') + '
    '; + } + + function preview(item, build) { + var modular = list.kind === 'modular', id = item.id; + 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'); var previous = $('previousIcon'); 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 = ''; - if (kind === 'model') { + if (!modular) { links += 'Download the mesh (.nif)
    ' + 'Download the LXFML
    '; } - if (publicUrl) links += 'Open on the UGC server'; - if (canManage) { - links += '
    ' + + if (publicUrl && current.fileId) links += 'Open on the UGC server'; + if (canManage && current.fileId) { + links += '
    ' + '' + '
    '; - if (item.sharedBy > 1) links += '
    This icon is shared by ' + esc(item.sharedBy) + ' builds of the same modules.
    '; } if (item.error) links += '
    ' + esc(item.error) + '
    '; $('previewLinks').innerHTML = links; - var model = kind === 'model'; - ['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); }); - $('statsBox').innerHTML = model ? '' : '
    Modules: ' + esc(item.modules || '') + ' (combination ' + esc(item.combination || '') + - '). The game client puts cars and rockets together itself; only the icon is made, once per combination of modules.
    '; - UgcIconEditor.open(model ? { model: true, id: id } : { model: false, modules: item.modules || '', label: item.combination }); + ['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', modular); }); + $('statsBox').innerHTML = modular ? '
    Modules (combination ' + esc(item.key) + ', used by ' + esc(item.uses) + ' build' + (item.uses === 1 ? '' : 's') + + '). The game client puts cars and rockets together itself; the UGC server only draws the icon, once per combination.
    ' + modulesHtml(item) : ''; + $('referencesCard').classList.toggle('d-none', !modular); + 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(); - if (!model) return; + if (modular) return; Promise.all([fetchJson(fileUrl('model', id, 'stats.json')), fetchJson(fileUrl('model', id, 'previous.stats.json'))]).then(function (s) { if (!current || current.id !== id) return; @@ -247,12 +337,11 @@ current.statsLoaded = true; $('statsBox').innerHTML = statsTable(s[0], s[1]); var lods = (s[0] && s[0].lods) || [{ lod: 0 }]; - $('lodSelect').innerHTML = lods.map(function (l, i) { return ''; }).join(''); + $('lodSelect').innerHTML = lods.map(function (l, n) { return ''; }).join(''); $('versionSelect').querySelector('[value=previous]').disabled = !s[1]; $('versionSelect').value = 'current'; loadMesh(true); }); - var nifBox = $('nifViewer'), lxfmlBox = $('lxfmlViewer'); import('/js/ugc-viewer.js').then(function (module) { 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); }).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 ' + esc(x.propertyName || 'property ' + x.propertyId) + '' + (x.ownerName ? ' of ' + esc(x.ownerName) : ''); + if (x.type === 'mail') return 'In a mail to ' + esc(x.characterName || x.characterId) + ''; + return 'In the creator\'s ' + esc(x.inventory || 'inventory'); + }).join('
    ') || 'Not found placed, mailed or with its creator'; + } + function loadRefs() { + if (!refs) return; + var wanted = refs; + $('refRows').innerHTML = 'Loading…'; + 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 = '' + esc(d.error || 'Failed') + ''; return; } + refs.page = d.page; + $('refRows').innerHTML = d.items.map(function (b) { + return '' + esc(b.id) + '' + owner(b) + '' + + (b.accountId ? '' + esc(b.accountName || b.accountId) + '' : '') + '' + badge(b.state) + + (b.error ? '
    ' + esc(b.error) + '
    ' : '') + '' + whereText(b.where) + '' + + 'Find'; + }).join('') || 'No builds match.'; + 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) { 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) { var button = e.target.closest('[data-view]'); if (!button) return; - view = button.dataset.view; - page = 0; - try { localStorage.setItem('ugcView', view); } catch (err) { /* storage blocked */ } - this.querySelectorAll('[data-view]').forEach(function (b) { b.classList.toggle('active', b === button); }); - load(); + try { localStorage.setItem('ugcView', button.dataset.view); } catch (err) { /* storage blocked */ } + changeList({ view: button.dataset.view, page: 0 }, false); + }); + $('stateFilter').addEventListener('change', function () { changeList({ state: this.value, page: 0 }, true); }); + $('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; $('search').addEventListener('input', function () { var value = this.value.trim(); 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 }); }); $('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) { var remakeButton = e.target.closest('[data-remake]'), previewButton = e.target.closest('[data-preview]'); 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); $('gallery').addEventListener('click', onItemClick); @@ -318,20 +458,22 @@ $('reframeButton').addEventListener('click', function () { if (nifViewer) nifViewer.frame(); }); $('previewModal').addEventListener('hidden.bs.modal', function () { current = null; + refs = null; UgcIconEditor.close(); if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose(); lxfmlViewer = null; - // Closed here rather than with Back: take the item out of the address too - if (urlItem()) { - if (pushedItem) history.back(); - else history.replaceState(null, '', location.pathname); - } - pushedItem = false; + if (readUrlItem()) writeUrl(true); }); window.addEventListener('popstate', function () { - pushedItem = false; - openFromUrl(); + var item = readUrl(); + 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 ---- function showDeleteResult(box, d) { @@ -347,19 +489,19 @@ if (!button || !current) return; 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; - 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) { - 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 }; 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; - 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) { toast('Pick a filter first, or purge all', 'warning'); 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; } $('purgeResult').textContent = 'Deleting…'; @@ -381,13 +523,7 @@ var button = e.target.closest('[data-preset]'); var entry = button && kinds.find(function (k) { return k.kind === button.dataset.preset; }); if (!entry || !entry.sample) return; - if (entry.kind === 'model') { - 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 }); - } + openItem(entry.kind === 'model' ? 'model' : 'modular', String(entry.sample)); }; }).catch(function (e) { $('presetsList').textContent = 'Could not load the icon types: ' + e.message; }); } @@ -395,11 +531,9 @@ if (window.Live) Live.on('ugc', load); - var linked = urlItem(); - if (linked) kind = linked.kind; - $('kindButtons').querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b.dataset.kind === kind); }); - load(); - if (linked) openFromUrl(); + var linked = readUrl(); + writeUrl(false, linked); + load().then(function () { if (linked) openItem(linked.kind, linked.id, linked.build); }); loadStatus(); setInterval(loadStatus, 10000); })(); diff --git a/dDashboardServer/templates/ugc.jinja2 b/dDashboardServer/templates/ugc.jinja2 index 7bc29d6b3..b737769ba 100644 --- a/dDashboardServer/templates/ugc.jinja2 +++ b/dDashboardServer/templates/ugc.jinja2 @@ -49,10 +49,13 @@
    + - + +
    @@ -66,13 +69,16 @@
    - +
    IconIDOwnerStateLast attemptDetails
    -
    - - +
    + + + +
    +
    +
    References the builds that use these modules +
    +
    + +
    Build (blueprint id)OwnerAccountStateWhere it is
    + +
    @@ -170,6 +185,8 @@ 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; } .ugc-meter { height: .4rem; } + .ugc-modules img { width: 32px; height: 32px; } + tr.ugc-highlight > td { background-color: var(--bs-warning-bg-subtle); } {% endblock %}