From fd141c232b67faba5ed4d30ddf13ffc769c880e9 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 06:14:47 -0500 Subject: [PATCH] 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 --- dDashboardServer/routes/UgcLinks.cpp | 21 ++++++- dDashboardServer/routes/UgcRoutes.cpp | 21 +++++-- dDashboardServer/static/js/ugc-search.js | 66 -------------------- dDashboardServer/static/js/ugc.js | 36 ++++++++--- dDashboardServer/templates/header.jinja2 | 7 +-- dDashboardServer/templates/ugc-search.jinja2 | 35 ----------- dDashboardServer/templates/ugc.jinja2 | 7 ++- docs/Dashboard.md | 3 +- docs/UgcServer.md | 35 ++++++----- 9 files changed, 91 insertions(+), 140 deletions(-) delete mode 100644 dDashboardServer/static/js/ugc-search.js delete mode 100644 dDashboardServer/templates/ugc-search.jinja2 diff --git a/dDashboardServer/routes/UgcLinks.cpp b/dDashboardServer/routes/UgcLinks.cpp index 55efeae79..e364b74d1 100644 --- a/dDashboardServer/routes/UgcLinks.cpp +++ b/dDashboardServer/routes/UgcLinks.cpp @@ -1,5 +1,6 @@ #include "UgcLinks.h" +#include #include #include #include @@ -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(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"), diff --git a/dDashboardServer/routes/UgcRoutes.cpp b/dDashboardServer/routes/UgcRoutes.cpp index 863c5ac74..6ee3720a4 100644 --- a/dDashboardServer/routes/UgcRoutes.cpp +++ b/dDashboardServer/routes/UgcRoutes.cpp @@ -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(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{}; + 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(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"), diff --git a/dDashboardServer/static/js/ugc-search.js b/dDashboardServer/static/js/ugc-search.js deleted file mode 100644 index 28259f0e4..000000000 --- a/dDashboardServer/static/js/ugc-search.js +++ /dev/null @@ -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 'Not found'; - } - return item.where.map(function (w) { - if (w.type === 'property') { - // A model the player didn't name has the client's placeholder (Objects__name) - var given = w.modelName && !/^Objects_\d+_name$/.test(w.modelName); - var name = given ? ' as ' + esc(w.modelName) + '' : ''; - return '
On ' + fmt.property(w.propertyId, w.propertyName || 'Property ' + w.propertyId) + name + - ' (' + fmt.character(w.ownerId, w.ownerName) + ', ' + esc(w.zoneId) + ')' + - ' 3D
'; - } - if (w.type === 'mail') return '
In the mail to ' + fmt.character(w.characterId, w.characterName) + '
'; - return '
In ' + fmt.character(w.characterId, item.characterName) + '\'s ' + esc(inventoryName(w.inventory)) + '
'; - }).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 ? '' + label + ' ' + esc(item.ugcId) + '' : label + ' ' + esc(item.ugcId); - var detail = item.detail ? '
' + (item.kind === 'modular' ? 'Modules ' + esc(item.detail) + '' : esc(item.detail)) + '
' : ''; - var account = item.accountName ? '
' + (document.getElementById('ugcSearchPage').dataset.canAccounts ? fmt.link('/accounts/' + item.accountId, item.accountName) : esc(item.accountName)) + '
' : ''; - return '' + UgcLinks.icon(item, lot, 48) + '' + title + detail + '' + - '' + fmt.character(item.characterId, item.characterName) + account + '' + - '' + where(item) + '' + UgcLinks.state(item) + ''; - }).join('') : 'Nothing matches.'; - } - - 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 = 'Type something to search.'; 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(); -})(); diff --git a/dDashboardServer/static/js/ugc.js b/dDashboardServer/static/js/ugc.js index 620173f2e..0f9d95d13 100644 --- a/dDashboardServer/static/js/ugc.js +++ b/dDashboardServer/static/js/ugc.js @@ -95,8 +95,11 @@ '' + rows.map(function (r) { return ''; }).join('') + '
' + r[0] + '' + r[1] + '
'; } + // The creator's character, and account for those who may see accounts function owner(i) { - return i.characterName ? '' + esc(i.characterName) + '' : '' + esc(i.characterId) + ''; + var character = i.characterName ? '' + esc(i.characterName) + '' : '' + esc(i.characterId) + ''; + var account = i.accountId && i.accountName ? '
' + (DASH.can('accounts_view') ? fmt.link('/accounts/' + i.accountId, i.accountName) : esc(i.accountName)) + '
' : ''; + 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 '' + (i.bricks ? esc(i.bricks) + ' bricks
' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + '
'; }, 'bricks', true), column('File', function (i) { return '' + esc(fileName(i)) + '' + errorText(i); }, 'name'), + column('Where', function (i) { return '' + whereText(i.where, i) + ''; }), column('', function (i) { return '
' + actions(i, i.id) + '
'; }) ]), // 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 ''; }).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 ' + esc(x.characterName || x.characterId) + ''; - return 'In the creator\'s ' + esc(x.inventory || 'inventory'); - }).join('
') || 'Not found placed, mailed or with its creator'; + if (x.type === 'property') { + // A model the player didn't name has the client's placeholder (Objects__name) + var named = x.modelName && !/^Objects_\d+_name$/.test(x.modelName) ? ' as ' + esc(x.modelName) + '' : ''; + return '
On ' + fmt.link('/properties/' + x.propertyId, x.propertyName || 'property ' + x.propertyId) + named + + (x.ownerName ? ' of ' + fmt.character(x.ownerId, x.ownerName) : '') + + (x.modelId ? ' 3D' : '') + '
'; + } + if (x.type === 'mail') return '
In a mail to ' + fmt.character(x.characterId, x.characterName) + '
'; + return '
In ' + (item && item.characterName ? fmt.character(x.characterId, item.characterName) + '\'s ' : 'the creator\'s ') + esc(inventoryName(x.inventory)) + '
'; + }).join('') || 'Not found'; } // 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 ? '' + esc(b.accountName || b.accountId) + '' : ''; }, 'account'), column('State', function (b) { return badge(b.state) + errorText(b); }, 'state'), column('Made', function (b) { return '' + esc(madeText(b)) + ''; }), - column('Where it is', function (b) { return whereText(b.where); }), - column('', function (b) { return ''; }) + column('Where it is', function (b) { return whereText(b.where, b); }) ]; var refTable = null; function loadRefs() { diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index 9cab77c46..5c7dd2f08 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -13,7 +13,7 @@ {% if can.accounts_view %}Accounts{% endif %} {% if can.characters_view %}Characters{% endif %} {% if can.properties_view %}Properties{% endif %} - {% if can.properties_view %}UGC Search{% endif %} + {% if can.properties_view %}UGC{% endif %} {% set inboxPages = ["moderation", "player_reports", "bug_reports"] %} @@ -120,8 +120,8 @@ {% 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 %} diff --git a/dDashboardServer/templates/ugc-search.jinja2 b/dDashboardServer/templates/ugc-search.jinja2 deleted file mode 100644 index 725f727e2..000000000 --- a/dDashboardServer/templates/ugc-search.jinja2 +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.jinja2" %} - -{% block title %}UGC Search - DarkflameServer{% endblock %} - -{% block content %} -
-
-

UGC Search

-

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 owner:, property:, model:, id: or lot:.

-
- -
-
-
- - - -
CreationMade byWhereUGC server
Type something to search.
-
-
-
-{% endblock %} - -{% block scripts %} - - -{% endblock %} diff --git a/dDashboardServer/templates/ugc.jinja2 b/dDashboardServer/templates/ugc.jinja2 index 744fb9d70..710e47566 100644 --- a/dDashboardServer/templates/ugc.jinja2 +++ b/dDashboardServer/templates/ugc.jinja2 @@ -8,7 +8,8 @@

UGC Server

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 docs/UgcServer.md.

+ 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 docs/UgcServer.md.

@@ -75,7 +76,7 @@
-
IconIDOwnerStateMadeTookCPURAM (est.)SavedSizeFile
+ IconIDOwnerStateMadeTookCPURAM (est.)SavedSizeFileWhere
IconIDOwnerStateMadeTookCPURAM (est.)SizeFileWhere
@@ -172,7 +173,7 @@
References the builds that use these modules
-
Build (blueprint id)OwnerAccountStateMadeWhere it is
+ Build (blueprint id)OwnerAccountStateMadeWhere it is diff --git a/docs/Dashboard.md b/docs/Dashboard.md index 1f5ac74fb..4ff1d5477 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -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 diff --git a/docs/UgcServer.md b/docs/UgcServer.md index 04f6114ed..dd48e8042 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -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__.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=&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=&kind=modular` (what the property and character pages link to), opens its assembly (`GET /api/ugc/assembly/of/`) 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=&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: