feat(dashboard): the UGC page replaces UGC Search

The UGC Search page and the UGC page searched the same things (owner,
account, property, the name and description a player gave a model, LOT,
UGC / blueprint id) with the same SQL. The UGC page's lists are now the
one search:

- models' List view has a Where column: placed on a property (with the
  name given there and a link to the model in the property's 3D view),
  in a mail or in the creator's inventories, or not found
  (/api/ugc?where=1, UgcLinks::Whereabouts as before)
- owners link to their character and, with accounts_view, account
- while searching, the kind buttons say how many models and how many
  cars and rockets match (/api/ugc "matches")
- /ugc_search?q= redirects to /ugc?view=list&q=; the menu's UGC Search
  entry is now UGC, which opens the UGC page (it was only under Server
  Admin, for ugc_manage)
- References lose their Find button (Where it is has the same) and name
  the inventory a build is in

The ugc-search page and script are removed; /api/ugc_links/search stays
for API users.

Check: the menu's UGC entry; an old /ugc_search?q= link opens the UGC
list with the search filled in; owner:, account:, property:, name:,
lot: and id: searches, and the counts on the kind buttons; the Where
column's links (property, 3D, character, account).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 06:14:47 -05:00
parent e159d7b768
commit fd141c232b
9 changed files with 91 additions and 140 deletions

View File

@@ -1,5 +1,6 @@
#include "UgcLinks.h"
#include <cctype>
#include <map>
#include <memory>
#include <set>
@@ -25,6 +26,17 @@ namespace {
constexpr size_t MAX_INVENTORIES = 25;
// A query value for an address: letters, digits and -._~ as they are, everything else %XX
std::string UrlEncode(std::string_view value) {
static constexpr char HEX[] = "0123456789ABCDEF";
std::string out;
for (const unsigned char c : value) {
if (std::isalnum(c) || c == '-' || c == '.' || c == '_' || c == '~') out += static_cast<char>(c);
else out += { '%', HEX[c >> 4], HEX[c & 15] };
}
return out;
}
// ---- Who may see a creation ----
// The property page's rule (as /api/properties/:id): properties_view, or the owner with own_properties
@@ -128,9 +140,14 @@ namespace UgcLinks {
}
void RegisterRoutes() {
Route(eHTTPMethod::GET, "/ugc_search", Perm("properties_view"), "Find players' models, cars and rockets and where they are",
// The UGC page's lists replaced the search page: its links (?q=) open them with the search filled in
Route(eHTTPMethod::GET, "/ugc_search", Perm("properties_view"), "Moved: the UGC page's lists (/ugc?view=list&q=)",
[](HTTPReply& reply, const HTTPContext& context) {
RenderPage(reply, context, "ugc-search.jinja2", "ugc_search", { { "query", QueryValue(context.queryString, "q").substr(0, 100) } });
const auto query = QueryValue(context.queryString, "q").substr(0, 100);
reply.status = eHTTPStatusCode::FOUND;
reply.location = "/ugc?view=list" + (query.empty() ? std::string() : "&q=" + UrlEncode(query));
reply.message = "";
reply.contentType = eContentType::TEXT_HTML;
});
Route(eHTTPMethod::GET, "/api/ugc_links/search", Perm("properties_view"),

View File

@@ -284,8 +284,8 @@ namespace UgcRoutes {
"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|slowest|made|cpu|memory|savings (models) or newest|oldest|owner|name|modules|made|slowest|cpu|memory|references (assemblies), "
"reverse=1 (the sort's other direction), page= (from 0), size= (1-200). {items, total, page, size, counts, totals, kinds, ugcPublicUrl, "
"canManage}",
"reverse=1 (the sort's other direction), page= (from 0), size= (1-200), where=1 (models: where each is, as an assembly's builds). {items, "
"total, page, size, counts, totals, matches: {model, modular} (while searching: how many of each kind match), kinds, ugcPublicUrl, canManage}",
[](HTTPReply& reply, const HTTPContext& context) {
const bool modular = QueryValue(context.queryString, "kind") == "modular";
auto parsed = ParseListSearch(QueryValue(context.queryString, "q").substr(0, 100));
@@ -295,6 +295,7 @@ namespace UgcRoutes {
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";
const bool where = QueryValue(context.queryString, "where") == "1";
const auto kinds = IconKinds();
// A page of models, and how many match
@@ -345,10 +346,21 @@ namespace UgcRoutes {
nlohmann::json items = nlohmann::json::array();
uint64_t total = 0;
nlohmann::json matches = nullptr;
const bool searching = !parsed.search.text.empty() || parsed.search.number.has_value();
if (!modular) {
const auto [entries, count] = models(page * size, size);
total = count;
for (const auto& entry : entries) items.push_back(EntryJson(entry));
const auto whereabouts = where ? UgcLinks::Whereabouts(entries) : std::map<LWOOBJID, nlohmann::json>{};
for (const auto& entry : entries) {
auto item = EntryJson(entry);
if (where) {
const auto found = whereabouts.find(entry.id);
item["where"] = found == whereabouts.end() ? nlohmann::json::array() : found->second;
}
items.push_back(std::move(item));
}
if (searching) matches = { { "model", total }, { "modular", assemblies().size() } };
} else {
const auto list = assemblies();
total = list.size();
@@ -370,11 +382,12 @@ namespace UgcRoutes {
{ "characterName", a.characterName }, { "accountId", a.accountId }, { "accountName", a.accountName }, { "processedAt", a.processedAt },
{ "processMs", a.processMs }, { "processCpuMs", a.processCpuMs }, { "processMemoryKb", a.processMemoryKb } });
}
if (searching) matches = { { "model", models(0, 1).second }, { "modular", total } };
}
JsonSuccess(reply, { { "counts", { { "model", Counts(Database::Get()->GetUgcProcessCounts()) }, { "modular", Counts(Database::Get()->GetModularBuildProcessCounts()) } } },
{ "totals", { { "model", Totals(Database::Get()->GetUgcProcessTotals(false)) }, { "modular", Totals(Database::Get()->GetUgcProcessTotals(true)) } } },
{ "items", items }, { "total", total }, { "page", page }, { "size", size }, { "more", static_cast<uint64_t>(page + 1) * size < total }, { "kinds", kinds },
{ "ugcPublicUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } });
{ "matches", matches }, { "ugcPublicUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } });
});
Route(eHTTPMethod::GET, "/api/ugc/assembly/builds", Perm("properties_view"),

View File

@@ -1,66 +0,0 @@
/**
* The UGC search (/ugc_search): players' creations by name, id or LOT and where each one is (/api/ugc_links/search).
* ?q= fills the search in; the address keeps the last search so it can be shared.
*/
(function () {
var input = document.getElementById('ugcSearchInput');
var rows = document.getElementById('ugcSearchRows');
var status = document.getElementById('ugcSearchStatus');
var latest = 0, timer = null;
var INVENTORIES = { 5: 'Models', 12: 'Vault models', 14: 'Brick-building models' };
function inventoryName(type) { return (window.Labels && Labels.name('inventories', type)) || INVENTORIES[type] || 'Inventory ' + type; }
function where(item) {
if (!item.where.length) {
return '<span class="text-body-secondary small" title="Not placed on a property, not in the mail and not in its creator\'s inventories: traded, sold or deleted">Not found</span>';
}
return item.where.map(function (w) {
if (w.type === 'property') {
// A model the player didn't name has the client's placeholder (Objects_<lot>_name)
var given = w.modelName && !/^Objects_\d+_name$/.test(w.modelName);
var name = given ? ' as <span class="text-break">' + esc(w.modelName) + '</span>' : '';
return '<div>On ' + fmt.property(w.propertyId, w.propertyName || 'Property ' + w.propertyId) + name +
' <span class="small text-body-secondary">(' + fmt.character(w.ownerId, w.ownerName) + ', ' + esc(w.zoneId) + ')</span>' +
' <a class="small" href="/properties/' + esc(w.propertyId) + '/3d#model=' + esc(w.modelId) + '">3D</a></div>';
}
if (w.type === 'mail') return '<div>In the mail to ' + fmt.character(w.characterId, w.characterName) + '</div>';
return '<div>In ' + fmt.character(w.characterId, item.characterName) + '\'s ' + esc(inventoryName(w.inventory)) + '</div>';
}).join('');
}
function render(items) {
rows.innerHTML = items.length ? items.map(function (item) {
var lot = item.kind === 'modular' ? 6416 : 6662;
var label = item.kind === 'modular' ? 'Car or rocket' : 'Model';
var title = item.link ? '<a href="' + esc(item.link) + '">' + label + ' ' + esc(item.ugcId) + '</a>' : label + ' ' + esc(item.ugcId);
var detail = item.detail ? '<div class="small text-body-secondary text-break">' + (item.kind === 'modular' ? 'Modules <code>' + esc(item.detail) + '</code>' : esc(item.detail)) + '</div>' : '';
var account = item.accountName ? '<div class="small text-body-secondary">' + (document.getElementById('ugcSearchPage').dataset.canAccounts ? fmt.link('/accounts/' + item.accountId, item.accountName) : esc(item.accountName)) + '</div>' : '';
return '<tr><td data-label="">' + UgcLinks.icon(item, lot, 48) + '</td><td data-label="Creation">' + title + detail + '</td>' +
'<td data-label="Made by">' + fmt.character(item.characterId, item.characterName) + account + '</td>' +
'<td data-label="Where">' + where(item) + '</td><td data-label="UGC server">' + UgcLinks.state(item) + '</td></tr>';
}).join('') : '<tr><td colspan="5" class="text-body-secondary">Nothing matches.</td></tr>';
}
function search() {
var q = input.value.trim();
var request = ++latest;
var url = new URL(window.location.href);
if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
history.replaceState(null, '', url);
if (!q) { rows.innerHTML = '<tr><td colspan="5" class="text-body-secondary">Type something to search.</td></tr>'; status.textContent = ''; return; }
status.textContent = 'Searching…';
api.get('/api/ugc_links/search?q=' + encodeURIComponent(q)).then(function (d) {
if (request !== latest) return;
var items = (d && d.items) || [];
status.textContent = items.length + ' found' + (items.length >= 50 ? ' (the newest 50 of each kind; narrow the search for more)' : '');
render(items);
}).catch(function () { if (request === latest) status.textContent = 'The search failed.'; });
}
input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(search, 300); });
document.getElementById('ugcSearchForm').addEventListener('submit', function (e) { e.preventDefault(); clearTimeout(timer); search(); });
input.value = document.getElementById('ugcSearchPage').dataset.query || '';
if (input.value) search();
input.focus();
})();

View File

@@ -95,8 +95,11 @@
'<table class="table table-sm mb-0 small"><tbody>' + rows.map(function (r) { return '<tr><th class="fw-normal text-body-secondary">' + r[0] + '</th><td>' + r[1] + '</td></tr>'; }).join('') +
'</tbody></table></div></div></div>';
}
// The creator's character, and account for those who may see accounts
function owner(i) {
return i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
var character = i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
var account = i.accountId && i.accountName ? '<div class="small text-body-secondary">' + (DASH.can('accounts_view') ? fmt.link('/accounts/' + i.accountId, i.accountName) : esc(i.accountName)) + '</div>' : '';
return character + account;
}
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)
@@ -183,6 +186,7 @@
}, 'savings', true),
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" title="' + esc(i.detail || '') + '">' + esc(fileName(i)) + '</span>' + errorText(i); }, 'name'),
column('Where', function (i) { return '<span class="small">' + whereText(i.where, i) + '</span>'; }),
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.id) + '</div>'; })
]),
// The models' columns but Saved: an assembly's ID and owner are its newest build's, its size its modules, its
@@ -246,6 +250,11 @@
.map(function (k) { return '<option value="' + esc(k.kind) + '">' + esc(k.label) + '</option>'; }).join('');
$('typeFilter').value = list.type;
}
// While searching, how many of each kind match (the search looks in both)
$('kindButtons').querySelectorAll('[data-kind]').forEach(function (b) {
var count = d.matches ? d.matches[b.dataset.kind] : null;
b.textContent = (b.dataset.kind === 'modular' ? 'Cars and rockets' : 'Models') + (count === null || count === undefined ? '' : ' (' + Number(count).toLocaleString() + ')');
});
$('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) +
@@ -269,7 +278,7 @@
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) {
api.get('/api/ugc?' + filterQuery() + '&' + orderQuery(columns, data.order) + '&page=' + Math.floor(data.start / size) + '&size=' + size + (kind === 'model' ? '&where=1' : '')).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;
@@ -507,12 +516,22 @@
// ---- an assembly's references: the builds that use it, and where they are ----
var refs = null, refTimer = null;
function whereText(w) {
var INVENTORIES = { 5: 'Models', 12: 'Vault models', 14: 'Brick-building models' };
function inventoryName(type) { return (window.Labels && Labels.name('inventories', type)) || INVENTORIES[type] || 'Inventory ' + type; }
// Where a creation is: placed on a property (with the name the player gave it there, and its 3D view), in a mail or
// in its creator's inventories (`item`: the creation, for its creator's name)
function whereText(w, item) {
return (w || []).map(function (x) {
if (x.type === 'property') return 'On ' + fmt.property(x.propertyId, x.propertyName || 'property ' + x.propertyId) + (x.ownerName ? ' of ' + esc(x.ownerName) : '');
if (x.type === 'mail') return 'In a mail to <a href="/characters/' + esc(x.characterId) + '">' + esc(x.characterName || x.characterId) + '</a>';
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>';
if (x.type === 'property') {
// A model the player didn't name has the client's placeholder (Objects_<lot>_name)
var named = x.modelName && !/^Objects_\d+_name$/.test(x.modelName) ? ' as <span class="text-break">' + esc(x.modelName) + '</span>' : '';
return '<div>On ' + fmt.link('/properties/' + x.propertyId, x.propertyName || 'property ' + x.propertyId) + named +
(x.ownerName ? ' of ' + fmt.character(x.ownerId, x.ownerName) : '') +
(x.modelId ? ' <a href="/properties/' + esc(x.propertyId) + '/3d#model=' + esc(x.modelId) + '">3D</a>' : '') + '</div>';
}
if (x.type === 'mail') return '<div>In a mail to ' + fmt.character(x.characterId, x.characterName) + '</div>';
return '<div>In ' + (item && item.characterName ? fmt.character(x.characterId, item.characterName) + '\'s ' : 'the creator\'s ') + esc(inventoryName(x.inventory)) + '</div>';
}).join('') || '<span class="text-body-secondary" title="Not placed on a property, not in the mail and not in its creator\'s inventories: traded, sold or deleted">Not found</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.
@@ -522,8 +541,7 @@
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('Made', function (b) { return '<span class="small">' + esc(madeText(b)) + '</span>'; }),
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>'; })
column('Where it is', function (b) { return whereText(b.where, b); })
];
var refTable = null;
function loadRefs() {

View File

@@ -13,7 +13,7 @@
{% if can.accounts_view %}<a href="/accounts" class="list-group-item list-group-item-action{% if current_page == "accounts" %} active{% endif %}">Accounts</a>{% endif %}
{% if can.characters_view %}<a href="/characters" class="list-group-item list-group-item-action{% if current_page == "characters" %} active{% endif %}">Characters</a>{% endif %}
{% if can.properties_view %}<a href="/properties" class="list-group-item list-group-item-action{% if current_page == "properties" %} active{% endif %}">Properties</a>{% endif %}
{% if can.properties_view %}<a href="/ugc_search" class="list-group-item list-group-item-action{% if current_page == "ugc_search" %} active{% endif %}">UGC Search</a>{% endif %}
{% if can.properties_view %}<a href="/ugc" class="list-group-item list-group-item-action{% if current_page == "ugc" %} active{% endif %}">UGC</a>{% endif %}
</div>
{% set inboxPages = ["moderation", "player_reports", "bug_reports"] %}
@@ -120,8 +120,8 @@
</div>
{% endif %}
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "webhooks", "client_assets", "ugc"] %}
{% if can.settings or can.permissions_manage or can.grants_manage or can.tasks_view or can.play_keys_manage or can.backups or can.webhooks or can.client_files or can.ugc_manage %}
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "webhooks", "client_assets"] %}
{% if can.settings or can.permissions_manage or can.grants_manage or can.tasks_view or can.play_keys_manage or can.backups or can.webhooks or can.client_files %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-admin" aria-expanded="{% if current_page in adminPages %}true{% else %}false{% endif %}" aria-controls="nav-admin">
Server Admin<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -134,7 +134,6 @@
{% if can.play_keys_manage %}<a href="/play_keys" class="list-group-item list-group-item-action{% if current_page == "play_keys" %} active{% endif %}">Play Keys</a>{% endif %}
{% if can.backups %}<a href="/backups" class="list-group-item list-group-item-action{% if current_page == "backups" %} active{% endif %}">Backups</a>{% endif %}
{% if can.webhooks %}<a href="/webhooks" class="list-group-item list-group-item-action{% if current_page == "webhooks" %} active{% endif %}">Webhooks</a>{% endif %}
{% if can.ugc_manage %}<a href="/ugc" class="list-group-item list-group-item-action{% if current_page == "ugc" %} active{% endif %}">UGC Server</a>{% endif %}
{% if can.client_files %}<a href="/client_assets" class="list-group-item list-group-item-action{% if current_page == "client_assets" %} active{% endif %}">Client Files</a>{% endif %}
</div>
</div>

View File

@@ -1,35 +0,0 @@
{% extends "base.jinja2" %}
{% block title %}UGC Search - DarkflameServer{% endblock %}
{% block content %}
<div id="ugcSearchPage" data-query="{{ query }}" data-can-accounts="{% if can.accounts_view %}1{% endif %}">
<div class="mb-3">
<h2 class="mb-1">UGC Search</h2>
<p class="text-body-secondary mb-0">Find the models, cars and rockets players built, what the UGC server made of them and where they are.
A number finds ids (the UGC / blueprint id, a placed model's object id, a property, character or account id) and LOTs;
text finds creators' character and account names, property names and the names players gave their models.
Search one field with <code>owner:</code>, <code>property:</code>, <code>model:</code>, <code>id:</code> or <code>lot:</code>.</p>
</div>
<form class="mb-3" id="ugcSearchForm" role="search">
<div class="input-group">
<input type="search" class="form-control" id="ugcSearchInput" placeholder="Name, id or LOT" aria-label="Search players' creations" autocomplete="off" maxlength="100">
<button class="btn btn-primary" type="submit">Search</button>
</div>
</form>
<div id="ugcSearchStatus" class="text-body-secondary small mb-2"></div>
<div class="card">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0 table-stack">
<thead><tr><th></th><th>Creation</th><th>Made by</th><th>Where</th><th>UGC server</th></tr></thead>
<tbody id="ugcSearchRows"><tr><td colspan="5" class="text-body-secondary">Type something to search.</td></tr></tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/ugc-links.js"></script>
<script src="/js/ugc-search.js"></script>
{% endblock %}

View File

@@ -8,7 +8,8 @@
<h2 class="mb-1">UGC Server</h2>
<p class="text-body-secondary mb-0">The UGC server makes a mesh and an icon for every model players build, and icons for their cars and rockets,
and serves them to the game client. New and changed models are picked up by themselves; a model that fails is tried a few times, then left
as failed until it's made again from here. See <code>docs/UgcServer.md</code>.</p>
as failed until it's made again from here. The search finds creations by owner, account, property, the name players gave them, LOT or id,
and the List view says where each one is. See <code>docs/UgcServer.md</code>.</p>
</div>
<div class="row g-2 mb-3" id="counts"></div>
@@ -75,7 +76,7 @@
<div id="gallery" class="ugc-gallery mb-2"></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>Took</th><th>CPU</th><th>RAM (est.)</th><th>Saved</th><th>Size</th><th>File</th><th></th></tr></thead></table></div>
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Made</th><th>Took</th><th>CPU</th><th>RAM (est.)</th><th>Saved</th><th>Size</th><th>File</th><th>Where</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>ID</th><th>Owner</th><th>State</th><th>Made</th><th>Took</th><th>CPU</th><th>RAM (est.)</th><th>Size</th><th>File</th><th>Where</th><th></th></tr></thead></table></div>
</div></div>
@@ -172,7 +173,7 @@
<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="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>Made</th><th>Where it is</th><th></th></tr></thead></table></div>
<thead><tr><th>Build (blueprint id)</th><th>Owner</th><th>Account</th><th>State</th><th>Made</th><th>Where it is</th></tr></thead></table></div>
</div>
</div>
</div></div>

View File

@@ -1073,7 +1073,8 @@ are time taken, so less is better.
The property page's model list shows each model's icon: for a player-built model (or a car or rocket) the icon the UGC
server made of it (the item's icon until it has), with its UGC state and a link to it on the UGC page. The character
page's inventories (models, vault models and the brick building ones included) show creations the same way.
**UGC Search** (`/ugc_search`, `properties_view`) finds creations and where they are: see docs/UgcServer.md.
The **UGC** page (`/ugc`, `properties_view`) finds creations by owner, account, property, name, LOT or id and says where they are (it
replaced the UGC Search page): see docs/UgcServer.md.
**Today's Top Properties** (Properties page, permission `feature_properties`, default GM 5). The game's news screen has
four "Today's Top Properties" slots, one per small property world: Block Yard, Nimbus Rock, Chantey Shanty and Raven

View File

@@ -777,7 +777,7 @@ it sends every 5 seconds with its workers, totals and storage), in the `server`
comes back, in the System Log (`UgcServer_*.log`) and crash dumps (`Crash_UgcServer_<start time>_<pid>.log` in `dump_folder`), and in
Prometheus (`darkflame_ugc_up`, `darkflame_ugc_items`, `darkflame_server_ugc_*{server="ugc"}`). See docs/Dashboard.md.
The UGC Server page (`/ugc`, Server Admin menu; `properties_view` to look, the new `ugc_manage` permission to make
The UGC Server page (`/ugc`, **UGC** in the menu under Properties; `properties_view` to look, the new `ugc_manage` permission to make
things again and to save icon values) reads the database: counts per state for models and for cars and rockets, and the
items as a gallery of their icons or a list. Player models are listed one by one (owner, state, attempts, last attempt,
bricks and triangles, file name, failure reason). Cars and rockets are listed as **assemblies**, one per combination of
@@ -786,24 +786,26 @@ columns and sorts (assemblies have no Saved): Icon, ID and Owner (an assembly's
many other owners), State (an assembly is made when any build of it is), Made, Took, CPU and RAM (an assembly's: the
latest make of any of its builds, and the cost of the make the UGC server did for it; builds that shared the made icon
cost nothing), Size (bricks and triangles; an assembly's module count), File (the file name; an assembly's build type,
named after the type's assembly object in `ModularBuildComponent`, and its modules) and, for assemblies, how many builds
and owners use it.
named after the type's assembly object in `ModularBuildComponent`, and its modules) and Where: for a model where it is
(below), for an assembly how many builds and owners use it. Owners link to their character and, with `accounts_view`,
account.
Both lists are paged on the server (`GET /api/ugc?kind=model|modular&q=&state=&type=&sort=&page=&size=`, with the
total), with numbered pages, first and last, a page to jump to and a page size kept per user. The search box takes
total; `where=1` adds each model's whereabouts; while searching, `matches` says how many of each kind match, shown on
the kind buttons), with numbered pages, first and last, a page to jump to and a page size kept per user. The search box takes
plain text (names, owners, ids) or field prefixes: `owner:`, `account:`, `property:`, `name:`, `lot:` or `module:` (a
LOT, or for assemblies a module's name), `id:`, `state:` and `kind:` (a car or rocket type). Models sort by newest,
oldest, most bricks, most triangles, recently made, slowest, most CPU, most RAM, most triangles saved, owner or file
name; assemblies the same (most modules for most bricks, no triangles or saved) and by most builds. The kind,
search, filters, sort, page and view are kept in the address, so Back and Forward and shared links work. The search
is the UGC search's SQL (`UgcLookupSql`, the same on MySQL and SQLite, `IUgcLookup::ListUgc`); assemblies are grouped
is `UgcLookupSql` (the same on MySQL and SQLite, `IUgcLookup::ListUgc`); assemblies are grouped
from the builds (`UgcAssemblies`). Buttons make one item, the failed ones or everything again (these only reset the
columns; the UGC server picks the rows up).
An assembly opens with its modules, the icon editor and **References**: the builds that use it (`GET
/api/ugc/assembly/builds?modules=&q=&page=`), with owner character and account, state and where each is (placed on a
property, in a mail, in its creator's inventories; the same lookup as the UGC search), paged and searchable. A link
to a build, `/ugc?item=<build id>&kind=modular` (what the UGC search and the character pages link to), opens its
property, in a mail, in its creator's inventories; the same lookup as the models' Where), paged and searchable. A link
to a build, `/ugc?item=<build id>&kind=modular` (what the property and character pages link to), opens its
assembly (`GET /api/ugc/assembly/of/<id>`) with that build highlighted in References.
**The icon editor** (on every opened item, and per type under **Icon presets per type**, which opens each type on an
@@ -848,16 +850,17 @@ were removed, vertices, shapes, timings, with the change since the version befor
### Finding creations and showing them elsewhere
**UGC Search** (`/ugc_search`, in the menu under Properties, `properties_view`; `?q=` fills the search in) finds
players' creations with `GET /api/ugc_links/search?q=`. A number matches the UGC / blueprint id, a placed model's
object id, the property it is placed on, the creator's character or account id, or a LOT (a model placed as that LOT,
or a car or rocket with that module); text matches the creator's character and account names, property names, the
name and description a player gave a placed model and the upload's file name. `owner:`, `property:`, `model:`, `id:`
and `lot:` search one field. Each result has its icon, state, creator and where it is: placed on a property (from
`properties_contents`), attached to a mail (a car's or rocket's subkey, a model item's blueprint in the attachment's
config) or in its creator's inventories (their saved XML); anything else is "Not found" (traded, sold or deleted).
The UGC page's search is the one search for creations (it replaced the UGC Search page; `/ugc_search?q=` redirects
to `/ugc?view=list&q=`). A number matches the UGC / blueprint id, a placed model's object id, the property it is placed
on, the creator's character or account id, or a LOT (a model placed as that LOT, or a car or rocket with that module);
text matches the creator's character and account names, property names, the name and description a player gave a
placed model and the upload's file name. A model's Where (`UgcLinks::Whereabouts`) is where it is: placed on a property
(from `properties_contents`, with the name the player gave it there and a link to that model in the property's 3D view),
attached to a mail (a model item's blueprint in the attachment's config; for a car's or rocket's build, its subkey) or
in its creator's inventories (their saved XML, looked at for the 25 first creators on a page); anything else is "Not
found" (traded, sold or deleted). `GET /api/ugc_links/search?q=` (API) answers the same search for both kinds at once.
Results, the property page and the character page link to a creation on the UGC page as
The property page and the character page link to a creation on the UGC page as
`/ugc?item=<id>&kind=model|modular`, for the page to open that item.
What the UGC server made shows on the pages that show a creation, to whoever may view that page: