feat(dashboard): UGC search, and UGC icons on property and character pages

UGC Search (/ugc_search) finds creations by name, id or LOT with where each
one is. Property pages and character inventories show the icon the UGC server
made of each creation, its state and a link to /ugc?item=&kind=, for whoever
may view the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 10:23:35 -05:00
parent dbce5f1240
commit 6b6897dd64
13 changed files with 884 additions and 6 deletions

View File

@@ -38,6 +38,20 @@
font-size: 10px;
opacity: 0.7;
}
/* A player's creation (ugc-links.js): the UGC server's icon, and a link to it in the UGC viewer */
.inv-item.inv-ugc { border-color: var(--bs-info-border-subtle); }
.inv-ugc-link {
position: absolute;
top: 1px;
right: 2px;
font-size: 9px;
font-weight: bold;
padding: 0 2px;
border-radius: 3px;
color: var(--bs-info-text-emphasis);
background: var(--bs-info-bg-subtle);
}
.inv-ugc-link:hover { text-decoration: underline; }
</style>
{% endblock %}
@@ -259,7 +273,7 @@
<div class="d-flex flex-wrap">
{% for item in inv.items %}
{% if can.reports_view and item.id != "" %}<a href="/reports?object={{ item.id }}" class="text-reset text-decoration-none" aria-label="Trace {{ item.name }}">{% endif %}
<div class="inv-item{% if item.equipped %}{{ " equipped" }}{% endif %}" data-lot="{{ item.lot }}" data-count="{{ item.count }}" data-equipped="{% if item.equipped %}1{% endif %}" data-bound="{% if item.bound %}1{% endif %}" title="{{ item.name }}">
<div class="inv-item{% if item.equipped %}{{ " equipped" }}{% endif %}" data-id="{{ item.id }}" data-lot="{{ item.lot }}" data-count="{{ item.count }}" data-equipped="{% if item.equipped %}1{% endif %}" data-bound="{% if item.bound %}1{% endif %}" title="{{ item.name }}">
<img src="/api/icon/{{ item.lot }}" alt="{{ item.name }}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2264%22 height=%2264%22><rect fill=%22%23333%22 width=%2264%22 height=%2264%22/><text x=%2232%22 y=%2236%22 fill=%22%23888%22 text-anchor=%22middle%22 font-size=%2210%22>{{ item.lot }}</text></svg>'">
{% if item.count > 1 %}<span class="inv-count">{% if item.count > 999 %}999+{% else %}{{ item.count }}{% endif %}</span>{% endif %}
{% if item.bound %}<span class="inv-lock">L</span>{% endif %}
@@ -479,6 +493,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
var inventory = document.getElementById('inventoryModal');
if (inventory) inventory.addEventListener('show.bs.modal', loadItemTooltips, { once: true });
// Models, cars and rockets the player built show the UGC server's icon of them
if (inventory) inventory.addEventListener('show.bs.modal', function () { UgcLinks.decorateInventory(characterId, inventory); }, { once: true });
});
var characterId = document.getElementById('restrictions').getAttribute('data-character-id');
@@ -633,6 +649,7 @@ function rescueCharacter() {
}
</script>
<script src="/js/rescue.js"></script>
<script src="/js/ugc-links.js"></script>
<script src="/js/character-tools.js"></script>
<script src="/js/character-missions.js"></script>
<script src="/js/related.js"></script>

View File

@@ -12,6 +12,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 %}
</div>
{% set inboxPages = ["moderation", "player_reports", "bug_reports"] %}

View File

@@ -108,6 +108,7 @@
{% endblock %}
{% block scripts %}
<script src="/js/ugc-links.js"></script>
<script type="module">
const propertyId = document.getElementById('property').getAttribute('data-property-id');
const UGC_LOT = 14;
@@ -183,7 +184,9 @@ function renderDetails(p) {
let loaded = null; // the property as last loaded, for the reject dialog
function load() {
api.get('/api/properties/' + propertyId).then((p) => {
// The UGC server's icon and state of each player-built model come with the property
Promise.all([api.get('/api/properties/' + propertyId), UgcLinks.forProperty(propertyId)]).then(([p, ugc]) => {
(p.models || []).forEach((m) => { m.ugc = ugc[m.id] || null; });
if (p.success === false) { setText('propName', p.error); return; }
loaded = p;
setText('propName', p.name || 'Unnamed property');
@@ -208,13 +211,21 @@ function load() {
});
}
// What the UGC server made of it, and a link to it in the UGC viewer (for those who may open it)
function ugcState(m) {
if (!m.ugc) return '';
return '<div class="small mt-1">UGC server: ' + UgcLinks.state(m.ugc) + (m.ugc.link ? ' <a href="' + esc(m.ugc.link) + '">Open in UGC viewer</a>' : '') + '</div>';
}
const labels = $('#modelsTable thead th').map(function () { return $(this).text().trim(); }).get();
const columns = [
{ data: null, render: (m, type) => {
const given = playerName(m.model_name);
if (type !== 'display') return (given || m.name) + ' ' + (m.model_description || '');
return (given ? '<div class="fw-semibold text-break">' + esc(given) + '</div><div class="small text-body-secondary">' + esc(m.name) + '</div>' : '<div>' + esc(m.name) + '</div>') +
(m.model_description ? '<div class="model-desc">' + esc(m.model_description) + '</div>' : '');
// A player-built model's icon is the one the UGC server made of it (the item's icon until it has)
const icon = m.ugc ? UgcLinks.icon(m.ugc, m.lot) : '<img src="/api/icon/' + esc(m.lot) + '" width="40" height="40" class="rounded" alt="" loading="lazy">';
return '<div class="d-flex align-items-center gap-2"><div class="flex-shrink-0">' + icon + '</div><div class="min-w-0">' + (given ? '<div class="fw-semibold text-break">' + esc(given) + '</div><div class="small text-body-secondary">' + esc(m.name) + '</div>' : '<div>' + esc(m.name) + '</div>') +
(m.model_description ? '<div class="model-desc">' + esc(m.model_description) + '</div>' : '') + '</div></div>';
} },
{ data: 'lot' },
{ data: 'behaviors', render: (d, type) => type === 'display' ? (d.length ? d.length + ': ' + d.map((b) => esc(b.name || 'Behavior')).join(', ') : muted('-')) : d.length },
@@ -225,13 +236,13 @@ const columns = [
{ data: null, render: (m, type) => {
if (m.lxfml_size === null || m.lxfml_size === undefined) {
if (type !== 'display') return m.ldf_config || '';
return m.ldf_config ? '<span class="small">Modular: <code>' + esc(m.ldf_config) + '</code></span>' : muted(m.lot === UGC_LOT ? 'Missing' : 'Prebuilt');
return m.ldf_config ? '<span class="small">Modular: <code>' + esc(m.ldf_config) + '</code></span>' + ugcState(m) : muted(m.lot === UGC_LOT ? 'Missing' : 'Prebuilt');
}
if (type !== 'display') return m.lxfml_size;
return '<span class="small">' + esc(bytes(m.lxfml_size)) + '</span> ' +
(m.is_optimized ? fmt.badge('Optimized', 'info') + ' ' : '') + (m.bake_ao ? fmt.badge('Baked AO', 'secondary') + ' ' : '') +
(m.ldf_config ? '<div class="small">Modular: <code>' + esc(m.ldf_config) + '</code></div>' : '') +
'<div class="small text-body-secondary">UGC ' + esc(m.ugc_id) + (m.filename ? ' &middot; ' + esc(m.filename) : '') + '</div>';
'<div class="small text-body-secondary">UGC ' + esc(m.ugc_id) + (m.filename ? ' &middot; ' + esc(m.filename) : '') + '</div>' + ugcState(m);
} },
{ data: 'position', orderable: false, render: (d) => '<span class="small text-nowrap">' + d.map((v) => v.toFixed(1)).join(', ') + '</span>' },
{ data: 'id', orderable: false, className: 'actions', render: (d) =>

View File

@@ -0,0 +1,35 @@
{% 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 %}