mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): UGC List view and References as sortable DataTables
The UGC page's List view (models and car/rocket assemblies) and an assembly's References panel are server-side DataTables like the rest of the dashboard: sort by clicking a column heading, in either direction, with the page length and sort remembered per user. They still read /api/ugc and /api/ugc/assembly/builds, so the prefix search and filters above the list keep working; the gallery keeps its own pager and sort. /api/ugc takes reverse=1 for a sort's other direction (the model list's SQL order and the assembly sort both flip), and the references endpoint sorts by id, owner, account or state. Tests cover the reversed orders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -139,7 +139,8 @@ namespace UgcAssemblies {
|
||||
return assembly.key;
|
||||
}
|
||||
|
||||
inline void Sort(std::vector<Assembly>& list, eSort sort, const std::map<uint32_t, ModuleInfo>& modules) {
|
||||
// reverse: the sort's other direction (the order is flipped as a whole)
|
||||
inline void Sort(std::vector<Assembly>& list, eSort sort, const std::map<uint32_t, ModuleInfo>& modules, bool reverse = false) {
|
||||
std::stable_sort(list.begin(), list.end(), [&](const Assembly& a, const Assembly& b) {
|
||||
switch (sort) {
|
||||
case eSort::OLDEST: return a.builds.back() < b.builds.back();
|
||||
@@ -151,5 +152,6 @@ namespace UgcAssemblies {
|
||||
default: return a.builds.front() > b.builds.front();
|
||||
}
|
||||
});
|
||||
if (reverse) std::reverse(list.begin(), list.end());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ namespace UgcRoutes {
|
||||
"A page of player models (kind=model) or of car and rocket assemblies (kind=modular: one per combination of modules, however many builds use "
|
||||
"it). Query: q= (\"state:\", \"kind:\"/\"type:\" (a build type, e.g. build6), \"owner:\", \"account:\", \"property:\", \"name:\", "
|
||||
"\"lot:\"/\"module:\" (a LOT or a module's name), \"id:\", or plain text across names, owners and ids), state=, type=, sort=newest|oldest|owner|name|"
|
||||
"bricks|triangles (models) or newest|oldest|references|name (assemblies), page= (from 0), size= (1-200). {items, total, page, size, counts, "
|
||||
"bricks|triangles (models) or newest|oldest|references|name (assemblies), reverse=1 (the sort's other direction), page= (from 0), size= (1-200). {items, total, page, size, counts, "
|
||||
"kinds, ugcPublicUrl, canManage}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const bool modular = QueryValue(context.queryString, "kind") == "modular";
|
||||
@@ -251,6 +251,7 @@ namespace UgcRoutes {
|
||||
const auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
|
||||
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(PAGE_SIZE), 1u, 200u);
|
||||
const auto sortText = QueryValue(context.queryString, "sort");
|
||||
const bool reverse = QueryValue(context.queryString, "reverse") == "1";
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
uint64_t total = 0;
|
||||
const auto kinds = IconKinds();
|
||||
@@ -261,6 +262,7 @@ namespace UgcRoutes {
|
||||
static const std::map<std::string, IUgcLookup::eSort> SORTS = { { "newest", IUgcLookup::eSort::NEWEST }, { "oldest", IUgcLookup::eSort::OLDEST },
|
||||
{ "owner", IUgcLookup::eSort::OWNER }, { "name", IUgcLookup::eSort::NAME }, { "bricks", IUgcLookup::eSort::BRICKS }, { "triangles", IUgcLookup::eSort::TRIANGLES } };
|
||||
if (const auto it = SORTS.find(sortText); it != SORTS.end()) query.sort = it->second;
|
||||
query.reverse = reverse;
|
||||
query.offset = page * size;
|
||||
query.limit = size;
|
||||
const auto [entries, count] = Database::Get()->ListUgc(IUgcLookup::eUgcKind::MODEL, query);
|
||||
@@ -294,7 +296,7 @@ namespace UgcRoutes {
|
||||
}
|
||||
auto assemblies = UgcAssemblies::Group(AllBuilds(), modules);
|
||||
std::erase_if(assemblies, [&](const auto& a) { return !UgcAssemblies::Matches(a, filter, modules); });
|
||||
UgcAssemblies::Sort(assemblies, UgcAssemblies::ParseSort(sortText).value_or(UgcAssemblies::eSort::NEWEST), modules);
|
||||
UgcAssemblies::Sort(assemblies, UgcAssemblies::ParseSort(sortText).value_or(UgcAssemblies::eSort::NEWEST), modules, reverse);
|
||||
total = assemblies.size();
|
||||
for (size_t i = static_cast<size_t>(page) * size; i < assemblies.size() && i < static_cast<size_t>(page + 1) * size; i++) {
|
||||
const auto& a = assemblies[i];
|
||||
@@ -321,7 +323,8 @@ namespace UgcRoutes {
|
||||
Route(eHTTPMethod::GET, "/api/ugc/assembly/builds", Perm("properties_view"),
|
||||
"The builds (ugc_modular_build rows) that use a combination of modules: {items: [{id, characterId, characterName, accountId, accountName, state, "
|
||||
"attempts, processedAt, error, where: [{type: property|mail|inventory, ...}]}], total, page, size}. Query: modules= (the combination), q= (owner, "
|
||||
"account, property or id, as the list's search), page=, size=, build= (a build to show: the page holding it is given)",
|
||||
"account, property or id, as the list's search), sort=id|owner|account|state (default id, the newest first; owner and account A to Z, state "
|
||||
"by state then newest), reverse=1 (the other direction), page=, size=, build= (a build to show: the page holding it is given)",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
auto asked = QueryValue(context.queryString, "modules");
|
||||
std::replace(asked.begin(), asked.end(), '-', '+');
|
||||
@@ -330,7 +333,15 @@ namespace UgcRoutes {
|
||||
const auto parsed = ParseListSearch(QueryValue(context.queryString, "q").substr(0, 100));
|
||||
auto builds = AllBuilds(parsed.search);
|
||||
std::erase_if(builds, [&](const auto& b) { return UgcModularKey::Normalize(b.detail) != key || (parsed.state && b.state != *parsed.state); });
|
||||
std::sort(builds.begin(), builds.end(), [](const auto& a, const auto& b) { return a.id > b.id; });
|
||||
const auto sort = QueryValue(context.queryString, "sort");
|
||||
const auto lowerName = [](const std::string& name) { return UgcAssemblies::Lower(name); };
|
||||
std::stable_sort(builds.begin(), builds.end(), [&](const auto& a, const auto& b) {
|
||||
if (sort == "owner" && lowerName(a.characterName) != lowerName(b.characterName)) return lowerName(a.characterName) < lowerName(b.characterName);
|
||||
if (sort == "account" && lowerName(a.accountName) != lowerName(b.accountName)) return lowerName(a.accountName) < lowerName(b.accountName);
|
||||
if (sort == "state" && a.state != b.state) return a.state < b.state;
|
||||
return a.id > b.id;
|
||||
});
|
||||
if (QueryValue(context.queryString, "reverse") == "1") std::reverse(builds.begin(), builds.end());
|
||||
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(25), 1u, 200u);
|
||||
auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
|
||||
if (const auto wanted = GeneralUtils::TryParse<LWOOBJID>(QueryValue(context.queryString, "build"))) {
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
$('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';
|
||||
$('sortSelect').classList.toggle('d-none', list.view !== 'gallery'); // the List view sorts by its column headings
|
||||
$('pageSize').value = String(pageSize());
|
||||
}
|
||||
|
||||
@@ -98,23 +99,47 @@
|
||||
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() {
|
||||
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>'
|
||||
: '<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>';
|
||||
function actions(i, remakeId) {
|
||||
return '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>' +
|
||||
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(remakeId) + '">Make again</button>' : '');
|
||||
}
|
||||
function row(i) {
|
||||
var view = '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>';
|
||||
var error = i.error ? '<div class="small text-danger">' + esc(i.error) + '</div>' : '';
|
||||
if (list.kind === 'modular') {
|
||||
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 +
|
||||
'</td><td>' + badge(i.state) + '</td><td>' + esc(i.uses) + '</td><td>' + esc(i.owners) + '</td><td class="text-end text-nowrap">' + view +
|
||||
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.iconBuild) + '">Make again</button>' : '') + '</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>';
|
||||
function errorText(i) { return i.error ? '<div class="small text-danger">' + esc(i.error) + '</div>' : ''; }
|
||||
|
||||
// The List view's columns. A column with a sort key sorts on the server by that key; desc marks the key's own
|
||||
// direction (e.g. "bricks" is the most first), so clicking the other way asks for the reverse.
|
||||
function column(title, render, sort, desc) {
|
||||
return { data: null, title: title, orderable: !!sort, sortKey: sort, desc: !!desc, orderSequence: desc ? ['desc', 'asc'] : ['asc', 'desc'],
|
||||
render: function (x, type, i) { return render(i); } };
|
||||
}
|
||||
var COLUMNS = {
|
||||
model: [
|
||||
column('Icon', function (i) { return iconImg(i, 48); }),
|
||||
column('ID', function (i) { return '<span class="small">' + esc(i.id) + '</span>'; }, 'newest', true),
|
||||
column('Owner', owner, 'owner'),
|
||||
column('State', function (i) {
|
||||
return badge(i.state) + (i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + waitBadge(i);
|
||||
}),
|
||||
column('Made', function (i) { return '<span class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</span>'; }),
|
||||
column('Size', function (i) { return '<span class="small">' + (i.bricks ? esc(i.bricks) + ' bricks<br>' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + '</span>'; }, 'bricks', true),
|
||||
column('File', function (i) { return '<span class="small">' + esc(i.detail || '') + '</span>' + errorText(i); }, 'name'),
|
||||
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.id) + '</div>'; })
|
||||
],
|
||||
modular: [
|
||||
column('Icon', function (i) { return iconImg(i, 48); }),
|
||||
column('Newest build', function (i) { return '<span class="small">' + esc(i.newestBuild) + '</span>'; }, 'newest', true),
|
||||
column('Type', function (i) { return esc(i.kindLabel || i.buildType); }),
|
||||
column('Modules', function (i) { return '<span class="small">' + esc(moduleNames(i)) + '</span><div><code>' + esc(i.key) + '</code></div>' + errorText(i); }, 'name'),
|
||||
column('State', function (i) { return badge(i.state); }),
|
||||
column('Builds', function (i) { return esc(i.uses); }, 'references', true),
|
||||
column('Owners', function (i) { return esc(i.owners); }),
|
||||
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.iconBuild) + '</div>'; })
|
||||
]
|
||||
};
|
||||
// The sort a DataTables order asks for, as /api/ugc's sort= and reverse=
|
||||
function orderQuery(columns, order) {
|
||||
var o = order && order[0], c = o && columns[o.column];
|
||||
if (!c || !c.sortKey) return 'sort=newest';
|
||||
return 'sort=' + c.sortKey + ((o.dir === 'desc') !== c.desc ? '&reverse=1' : '');
|
||||
}
|
||||
|
||||
// Numbered pages with first and last, around the current one
|
||||
@@ -140,35 +165,74 @@
|
||||
};
|
||||
}
|
||||
|
||||
// What every /api/ugc answer carries besides the items
|
||||
function applyMeta(d) {
|
||||
canManage = d.canManage;
|
||||
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);
|
||||
$('cacheCard').classList.toggle('d-none', !canManage);
|
||||
$('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular);
|
||||
}
|
||||
function filterQuery() {
|
||||
return 'kind=' + list.kind + '&q=' + encodeURIComponent(list.q) + '&state=' + list.state + '&type=' + encodeURIComponent(list.kind === 'modular' ? list.type : '');
|
||||
}
|
||||
|
||||
// The List view: a server-side DataTable per kind, made the first time it shows. Search and filters stay the
|
||||
// page's own (above); DataTables does the paging, and sorting by the column headings.
|
||||
var tables = {}, tableLoaded = null;
|
||||
function listTable(kind) {
|
||||
if (tables[kind]) return tables[kind];
|
||||
var columns = COLUMNS[kind];
|
||||
tables[kind] = serverTable('#' + (kind === 'modular' ? 'ugcAssemblyTable' : 'ugcModelTable'), null, columns, { dataTable: {
|
||||
order: [[1, 'desc']],
|
||||
pageLength: 50,
|
||||
autoWidth: false,
|
||||
layout: { topStart: 'pageLength', topEnd: 'info', bottomStart: null, bottomEnd: 'paging' },
|
||||
language: { emptyTable: 'Nothing here.', zeroRecords: 'Nothing matches.' },
|
||||
ajax: function (data, callback) {
|
||||
var size = data.length > 0 ? data.length : 200;
|
||||
api.get('/api/ugc?' + filterQuery() + '&' + orderQuery(columns, data.order) + '&page=' + Math.floor(data.start / size) + '&size=' + size).then(function (d) {
|
||||
if (!d.success) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [], error: d.error || 'Failed' }); return; }
|
||||
applyMeta(d);
|
||||
if (list.kind === kind) items = d.items;
|
||||
callback({ draw: data.draw, recordsTotal: d.total, recordsFiltered: d.total, data: d.items });
|
||||
if (tableLoaded) { tableLoaded(); tableLoaded = null; }
|
||||
}).catch(function () { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); });
|
||||
}
|
||||
} });
|
||||
return tables[kind];
|
||||
}
|
||||
|
||||
var loadSequence = 0;
|
||||
function load() {
|
||||
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) {
|
||||
var gallery = list.view === 'gallery';
|
||||
$('gallery').classList.toggle('d-none', !gallery);
|
||||
$('pagerBar').classList.toggle('d-none', !gallery);
|
||||
$('listCard').classList.toggle('d-none', gallery);
|
||||
$('modelTableBox').classList.toggle('d-none', list.kind !== 'model');
|
||||
$('assemblyTableBox').classList.toggle('d-none', list.kind !== 'modular');
|
||||
if (!gallery) {
|
||||
return new Promise(function (resolve) {
|
||||
tableLoaded = resolve;
|
||||
var fresh = !tables[list.kind];
|
||||
var table = listTable(list.kind);
|
||||
if (!fresh) table.ajax.reload(null, true);
|
||||
});
|
||||
}
|
||||
var size = pageSize(), sequence = ++loadSequence;
|
||||
return api.get('/api/ugc?' + filterQuery() + '&sort=' + list.sort + '&page=' + list.page + '&size=' + size).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 = '<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);
|
||||
$('cacheCard').classList.toggle('d-none', !canManage);
|
||||
$('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('') || '<div class="text-body-secondary">' + nothing + '</div>';
|
||||
else {
|
||||
$('listHead').innerHTML = listHead();
|
||||
$('rows').innerHTML = items.map(row).join('') || '<tr><td colspan="8" class="text-body-secondary">' + nothing + '</td></tr>';
|
||||
}
|
||||
applyMeta(d);
|
||||
$('gallery').innerHTML = items.map(tile).join('') || '<div class="text-body-secondary">' + (list.q || list.state || list.type ? 'Nothing matches.' : 'Nothing here.') + '</div>';
|
||||
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;
|
||||
@@ -377,31 +441,49 @@
|
||||
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>';
|
||||
}
|
||||
// The builds that use the open assembly: a server-side DataTable, made once and reloaded for each assembly. A
|
||||
// linked build (refs.highlight) opens on the page holding it, marked.
|
||||
var REF_COLUMNS = [
|
||||
column('Build (blueprint id)', function (b) { return '<code>' + esc(b.id) + '</code>'; }, 'id', true),
|
||||
column('Owner', owner, 'owner'),
|
||||
column('Account', function (b) { return b.accountId ? '<a href="/accounts/' + esc(b.accountId) + '">' + esc(b.accountName || b.accountId) + '</a>' : ''; }, 'account'),
|
||||
column('State', function (b) { return badge(b.state) + errorText(b); }, 'state'),
|
||||
column('Where it is', function (b) { return whereText(b.where); }),
|
||||
column('', function (b) { return '<div class="text-end"><a class="btn btn-sm btn-outline-secondary" href="/ugc_search?q=' + encodeURIComponent('id:' + b.id) + '">Find</a></div>'; })
|
||||
];
|
||||
var refTable = null;
|
||||
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' });
|
||||
});
|
||||
if (refTable) { refTable.ajax.reload(null, true); return; }
|
||||
refTable = serverTable('#refTable', null, REF_COLUMNS, { dataTable: {
|
||||
order: [[0, 'desc']],
|
||||
pageLength: 25,
|
||||
autoWidth: false,
|
||||
layout: { topStart: 'pageLength', topEnd: 'info', bottomStart: null, bottomEnd: 'paging' },
|
||||
language: { emptyTable: 'No builds use these modules.', zeroRecords: 'No builds match.' },
|
||||
createdRow: function (tr, b) { if (refs && b.id === refs.highlight) tr.classList.add('ugc-highlight'); },
|
||||
ajax: function (data, callback) {
|
||||
if (!refs) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); return; }
|
||||
var wanted = refs, size = data.length > 0 ? data.length : 200, highlight = refs.highlight && !refs.shown ? refs.highlight : '';
|
||||
var sort = orderQuery(REF_COLUMNS, data.order).replace('sort=newest', 'sort=id');
|
||||
api.get('/api/ugc/assembly/builds?modules=' + encodeURIComponent(refs.key) + '&q=' + encodeURIComponent(refs.q) + '&' + sort +
|
||||
'&page=' + Math.floor(data.start / size) + '&size=' + size + (highlight ? '&build=' + encodeURIComponent(highlight) : '')).then(function (d) {
|
||||
if (refs !== wanted) return;
|
||||
if (!d.success) { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [], error: d.error || 'Failed' }); return; }
|
||||
refs.shown = true;
|
||||
callback({ draw: data.draw, recordsTotal: d.total, recordsFiltered: d.total, data: d.items });
|
||||
// The linked build is on another page: go there (the server said which)
|
||||
if (highlight && d.page * size !== data.start) setTimeout(function () { refTable.page(d.page).draw('page'); }, 0);
|
||||
var marked = document.querySelector('#refTable .ugc-highlight');
|
||||
if (marked) marked.scrollIntoView({ block: 'nearest' });
|
||||
}).catch(function () { callback({ draw: data.draw, recordsTotal: 0, recordsFiltered: 0, data: [] }); });
|
||||
}
|
||||
} });
|
||||
}
|
||||
$('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);
|
||||
refTimer = setTimeout(function () { if (refs) { refs.q = value; refs.highlight = ''; loadRefs(); } }, 300);
|
||||
});
|
||||
|
||||
// ---- controls ----
|
||||
@@ -443,7 +525,7 @@
|
||||
if (item) preview(item);
|
||||
}
|
||||
}
|
||||
$('rows').addEventListener('click', onItemClick);
|
||||
$('listCard').addEventListener('click', onItemClick);
|
||||
$('gallery').addEventListener('click', onItemClick);
|
||||
$('gallery').addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onItemClick(e); } });
|
||||
$('previewLinks').addEventListener('click', function (e) {
|
||||
|
||||
@@ -72,12 +72,12 @@
|
||||
</div>
|
||||
|
||||
<div id="gallery" class="ugc-gallery mb-2"></div>
|
||||
<div class="card d-none" id="listCard">
|
||||
<table class="table table-sm align-middle mb-0 table-stack">
|
||||
<thead id="listHead"></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card d-none" id="listCard"><div class="card-body p-2">
|
||||
<div id="modelTableBox"><table class="table table-sm align-middle mb-0 w-100 table-stack" id="ugcModelTable">
|
||||
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Made</th><th>Size</th><th>File</th><th></th></tr></thead></table></div>
|
||||
<div id="assemblyTableBox" class="d-none"><table class="table table-sm align-middle mb-0 w-100 table-stack" id="ugcAssemblyTable">
|
||||
<thead><tr><th>Icon</th><th>Newest build</th><th>Type</th><th>Modules</th><th>State</th><th>Builds</th><th>Owners</th><th></th></tr></thead></table></div>
|
||||
</div></div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mt-2" id="pagerBar">
|
||||
<nav aria-label="Pages"><ul class="pagination pagination-sm mb-0" id="pager"></ul></nav>
|
||||
<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>
|
||||
@@ -170,11 +170,8 @@
|
||||
<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 class="card-body p-2"><table class="table table-sm small align-middle mb-0 w-100" id="refTable">
|
||||
<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></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
@@ -67,6 +67,7 @@ public:
|
||||
UgcSearch search;
|
||||
std::optional<IUgc::eProcessState> state;
|
||||
eSort sort{ eSort::NEWEST };
|
||||
bool reverse{}; // the sort's other direction (e.g. the fewest bricks first)
|
||||
uint32_t offset{};
|
||||
uint32_t limit{ 50 };
|
||||
};
|
||||
|
||||
@@ -50,13 +50,15 @@ namespace UgcLookupSql {
|
||||
inline std::string ListOrder(const IUgcLookup::UgcListQuery& query, bool modular) {
|
||||
using eSort = IUgcLookup::eSort;
|
||||
const std::string id = modular ? "b.ugc_id" : "u.id";
|
||||
// A sort's own direction, or the other one when the query is reversed; ties stay newest first
|
||||
const auto dir = [&query](bool descending) { return descending != query.reverse ? " DESC" : " ASC"; };
|
||||
switch (query.sort) {
|
||||
case eSort::OLDEST: return "ORDER BY " + id + " ASC ";
|
||||
case eSort::OWNER: return "ORDER BY c.name ASC, " + id + " DESC ";
|
||||
case eSort::NAME: return std::string("ORDER BY ") + (modular ? "b.ldf_config" : "u.filename") + " ASC, " + id + " DESC ";
|
||||
case eSort::BRICKS: return modular ? "ORDER BY " + id + " DESC " : "ORDER BY u.brick_count DESC, u.id DESC ";
|
||||
case eSort::TRIANGLES: return modular ? "ORDER BY " + id + " DESC " : "ORDER BY u.triangle_count DESC, u.id DESC ";
|
||||
default: return "ORDER BY " + id + " DESC ";
|
||||
case eSort::OLDEST: return "ORDER BY " + id + dir(false) + " ";
|
||||
case eSort::OWNER: return "ORDER BY c.name" + std::string(dir(false)) + ", " + id + " DESC ";
|
||||
case eSort::NAME: return std::string("ORDER BY ") + (modular ? "b.ldf_config" : "u.filename") + dir(false) + ", " + id + " DESC ";
|
||||
case eSort::BRICKS: return modular ? "ORDER BY " + id + dir(true) + " " : "ORDER BY u.brick_count" + std::string(dir(true)) + ", u.id DESC ";
|
||||
case eSort::TRIANGLES: return modular ? "ORDER BY " + id + dir(true) + " " : "ORDER BY u.triangle_count" + std::string(dir(true)) + ", u.id DESC ";
|
||||
default: return "ORDER BY " + id + dir(true) + " ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -170,8 +170,19 @@ TEST_F(UgcLookupSqlTests, ListsPagesWithCounts) {
|
||||
EXPECT_EQ(total, 2);
|
||||
query.sort = IUgcLookup::eSort::BRICKS;
|
||||
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1000, 1001 }));
|
||||
query.reverse = true; // the fewest bricks first
|
||||
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1001, 1000 }));
|
||||
query.reverse = false;
|
||||
query.sort = IUgcLookup::eSort::NAME; // boat before tower
|
||||
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1001, 1000 }));
|
||||
query.reverse = true; // tower before boat
|
||||
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1000, 1001 }));
|
||||
query.reverse = false;
|
||||
query.sort = IUgcLookup::eSort::NEWEST;
|
||||
query.reverse = true; // the oldest first
|
||||
EXPECT_EQ(List(query, false).first, (std::vector<int64_t>{ 1000, 1001 }));
|
||||
query.reverse = false;
|
||||
query.sort = IUgcLookup::eSort::NAME;
|
||||
query.limit = 1;
|
||||
query.offset = 1;
|
||||
std::tie(ids, total) = List(query, false);
|
||||
@@ -251,6 +262,10 @@ TEST(UgcAssemblies, GroupsBuildsByTheirModules) {
|
||||
EXPECT_EQ(list.front().key, "4713-4714-4715"); // Classic before Racing
|
||||
UgcAssemblies::Sort(list, UgcAssemblies::eSort::OLDEST, modules);
|
||||
EXPECT_EQ(list.front().key, "4713-4714-4715"); // build 10
|
||||
UgcAssemblies::Sort(list, UgcAssemblies::eSort::REFERENCES, modules, true);
|
||||
EXPECT_EQ(list.back().key, "4713-4714-4715"); // the most builds last
|
||||
UgcAssemblies::Sort(list, UgcAssemblies::eSort::NAME, modules, true);
|
||||
EXPECT_EQ(list.back().key, "4713-4714-4715"); // Racing before Classic
|
||||
EXPECT_EQ(UgcAssemblies::ParseSort("uses"), UgcAssemblies::eSort::REFERENCES);
|
||||
EXPECT_FALSE(UgcAssemblies::ParseSort("nonsense"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user