Files
DarkflameServer/dDashboardServer/templates/properties.jinja2
Aaron Kimbrell 3c004fd36d feat(dashboard): a 3D button beside every property link
fmt.property(id, name) links a property's page and adds a small 3D button
straight to its 3D view. Used everywhere a property is linked: the
properties list and top slots, moderation, character pages' related
properties, UGC search and the UGC page's placements, the home page, the
chat log, player reports and report notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:31:25 -05:00

169 lines
10 KiB
Django/Jinja

{% extends "base.jinja2" %}
{% block title %}Properties - DarkflameServer{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">Properties</h2>
<p class="text-body-secondary mb-0">Player properties &mdash; <span id="propertyCount">0</span> shown</p>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="pendingOnly" data-pref="properties.unapprovedOnly">
<label class="form-check-label" for="pendingOnly">Awaiting review only</label>
</div>
</div>
{% if can.feature_properties %}
<div class="card mb-4" id="topPropertiesCard">
<div class="card-header d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<h5 class="mb-0"><button type="button" class="btn btn-link p-0 text-body text-decoration-none fs-5 dropdown-toggle" data-bs-toggle="collapse" data-bs-target="#topPropertiesBody" aria-expanded="true" aria-controls="topPropertiesBody">Today's Top Properties</button></h5>
<details class="about-text"><summary>How the slots are picked</summary><div>The four slots of the game's news screen. Each slot belongs to one property world and can only show a property of that world, since the game names the slot's world whatever property is in it. Only approved public properties can be shown, and never one in two slots. <em>Full auto</em> shows each world's property with the most reputation; <em>Per slot</em> lets each slot show its world's <em>Auto</em> pick (the most reputation not shown in another slot), a picked one, or nothing. Players see a change the next time they open the news screen (within 30 seconds).</div></details>
<div class="small text-body-secondary" id="topPropertiesSetBy"></div>
</div>
<div class="btn-group btn-group-sm flex-shrink-0" role="group" aria-label="Top properties mode">
<input type="radio" class="btn-check" name="topPropertiesMode" id="topPropertiesPerSlot" value="per_slot" autocomplete="off">
<label class="btn btn-outline-primary" for="topPropertiesPerSlot">Per slot</label>
<input type="radio" class="btn-check" name="topPropertiesMode" id="topPropertiesFullAuto" value="full_auto" autocomplete="off">
<label class="btn btn-outline-primary" for="topPropertiesFullAuto">Full auto</label>
</div>
</div>
<div class="card-body collapse show" id="topPropertiesBody" data-pref-open="properties.topOpen">
<div class="row g-3" id="topPropertySlots"><div class="col text-body-secondary">Loading&hellip;</div></div>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-body">
<table id="propertiesTable" class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Owner</th>
<th>Moderation</th>
<th>Reputation</th>
<th title="Load estimate of the placed models, reported by the owner's game client">Performance cost</th>
<th>Zone</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(function () {
var pending = document.getElementById('pendingOnly');
var table = serverTable('#propertiesTable', '/api/tables/properties', [
{ data: 'id' },
{ data: 'name', render: function (d, t, row) { return t === 'display' ? fmt.property(row.id, d) : d; } },
{ data: 'owner_id', render: function (d, t, row) { return fmt.character(d, row.owner_name); } },
{ data: 'mod_approved', render: function (d) { return d ? fmt.badge('Approved', 'success') : fmt.badge('Unapproved', 'warning'); } },
{ data: 'reputation' },
{ data: 'performance_cost', render: function (d, t) { return t === 'display' ? (Number(d) ? Number(d).toFixed(1) : '<span class="text-body-secondary">-</span>') : d; } },
{ data: 'zone_id', render: function (d, t, row) { return fmt.zone(d, row.zone_name); } },
{ data: null, orderable: false, searchable: false, render: function (d) {
if (d.mod_approved || !DASH.can('moderate_properties')) return '<span class="text-body-secondary">-</span>';
return '<button class="btn btn-sm btn-outline-success" data-approve="' + esc(d.id) + '">Approve</button>';
}}
], { liveTable: 'properties', extra: function () { return { pending: pending.checked }; } });
table.on('draw.dt', function () { document.getElementById('propertyCount').textContent = table.page.info().recordsDisplay; });
pending.addEventListener('change', function () { table.ajax.reload(); });
$('#propertiesTable').on('click', '[data-approve]', function () {
if (!confirm('Approve this property?')) return;
api.action('/api/properties/' + this.getAttribute('data-approve') + '/approve', {}, 'Property approved').catch(function () {});
});
if (DASH.can('feature_properties')) {
var slotsEl = document.getElementById('topPropertySlots');
var modeInputs = document.querySelectorAll('input[name="topPropertiesMode"]');
var propLabel = function (p) { return (p.name || '(unnamed)') + ' by ' + (p.owner_name || '?') + ' \u2014 ' + Number(p.reputation || 0).toLocaleString() + ' rep'; };
var countLabel = function (n) { return n + ' approved public ' + (n === 1 ? 'property' : 'properties'); };
var renderSlots = function (state) {
var slots = state.slots || [];
var fullAuto = !!state.full_auto;
modeInputs.forEach(function (el) { el.checked = (el.value === 'full_auto') === fullAuto; el.disabled = false; });
document.getElementById('topPropertiesSetBy').textContent = state.settings_updated_at
? 'Switched to ' + (fullAuto ? 'full auto' : 'per slot') + ' by ' + (state.settings_updated_by || '?') + ' ' + new Date(state.settings_updated_at * 1000).toLocaleString() : '';
if (!slots.length) { slotsEl.innerHTML = '<div class="col text-body-secondary">No property worlds found in the client data.</div>'; return; }
slotsEl.innerHTML = slots.map(function (s) {
var showing = s.showing
? fmt.property(s.showing.id, s.showing.name) + ' <span class="text-body-secondary">by ' + esc(s.showing.owner_name) + '</span>'
: '<span class="text-body-secondary">Nothing (locked slot)</span>';
var warn = fullAuto ? '' : s.picked_unavailable ? '<div class="small text-warning mt-1">The picked property isn\'t an approved public property of ' + esc(s.zone_name) + ' (any more), so the slot falls back to Auto.</div>'
: s.picked_shown_elsewhere ? '<div class="small text-warning mt-1">The picked property is already shown in an earlier slot, so this one falls back to Auto.</div>' : '';
var changed = s.updated_at ? '<div class="small text-body-secondary mt-1">Set by ' + esc(s.updated_by || '?') + ' ' + esc(new Date(s.updated_at * 1000).toLocaleString()) + '</div>' : '';
var controls = fullAuto ? '<div class="small text-body-secondary mb-2">Full auto is on: this slot\'s own setting (' + esc(s.mode) + ') is kept for when you switch back to per slot.</div>' :
'<div class="small text-body-secondary mb-2"><span data-count>' + countLabel(s.candidate_total) + '</span></div>' +
'<select class="form-select form-select-sm mb-2" data-mode aria-label="What the slot shows">' +
'<option value="auto"' + (s.mode === 'auto' ? ' selected' : '') + '>Auto (most reputation)</option>' +
'<option value="picked"' + (s.mode === 'picked' ? ' selected' : '') + (s.candidate_total || s.picked ? '' : ' disabled') + '>Pick a property</option>' +
'<option value="empty"' + (s.mode === 'empty' ? ' selected' : '') + '>Empty</option>' +
'</select>' +
'<div class="mb-2" data-property-box' + (s.mode === 'picked' ? '' : ' hidden') + '><input type="search" class="form-control form-control-sm" data-property placeholder="Search by name or owner" aria-label="Property"></div>' +
'<button class="btn btn-sm btn-primary" data-save>Save</button>';
return '<div class="col-12 col-md-6 col-xl-3"><div class="border rounded p-3 h-100" data-slot="' + s.template_id + '">' +
'<div class="fw-semibold">' + esc(s.zone_name) + ' slot</div>' + controls +
'<div class="small mt-2">Showing: ' + showing + '</div>' + warn + (fullAuto ? '' : changed) +
'</div></div>';
}).join('');
if (fullAuto) return;
slots.forEach(function (s) {
var box = slotsEl.querySelector('[data-slot="' + s.template_id + '"]');
SearchSelect(box.querySelector('[data-property]'), {
value: s.picked ? s.property_id : '',
label: s.picked ? propLabel(s.picked) : '',
search: function (text) {
return api.get('/api/featured_properties/' + s.template_id + '/candidates?search=' + encodeURIComponent(text)).then(function (d) {
return (d.properties || []).map(function (p) { return { value: p.id, label: propLabel(p) }; });
});
}
});
});
};
var loadSlots = function () {
api.get('/api/featured_properties').then(function (d) {
if (!d.success) { slotsEl.innerHTML = '<div class="col text-danger">' + esc(d.error || 'Could not load the slots') + '</div>'; return; }
renderSlots(d);
}).catch(function () {});
};
modeInputs.forEach(function (el) {
el.addEventListener('change', function () {
if (!el.checked) return;
modeInputs.forEach(function (other) { other.disabled = true; });
api.action('/api/featured_properties', { full_auto: el.value === 'full_auto' }, el.value === 'full_auto' ? 'Top properties: full auto' : 'Top properties: per slot')
.then(renderSlots).catch(loadSlots);
});
});
slotsEl.addEventListener('change', function (e) {
if (!e.target.matches('[data-mode]')) return;
var box = e.target.closest('[data-slot]').querySelector('[data-property-box]');
box.hidden = e.target.value !== 'picked';
if (!box.hidden) box.querySelector('[data-property]').focus();
});
slotsEl.addEventListener('click', function (e) {
if (!e.target.matches('[data-save]')) return;
var box = e.target.closest('[data-slot]');
var mode = box.querySelector('[data-mode]').value;
var body = { mode: mode };
if (mode === 'picked') {
body.property_id = box.querySelector('[data-property]').dataset.value;
if (!body.property_id) { toast('Search for a property and pick one first', 'warning'); return; }
}
e.target.disabled = true;
api.action('/api/featured_properties/' + box.getAttribute('data-slot'), body, 'Top property slot saved').then(renderSlots).catch(function () { e.target.disabled = false; });
});
if (window.Live) { Live.on('featured_properties', loadSlots); Live.on('properties', loadSlots); }
loadSlots();
}
});
</script>
{% endblock %}