mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
12 KiB
Django/Jinja
196 lines
12 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 — <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. Only approved public properties can be shown, and never one in two slots. <em>Full auto</em> shows the four with the most reputation across every property world; <em>Per slot</em> lets each slot show a property of any property world (its location): <em>Auto</em> the location's one with 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…</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 fmt.link('/properties/' + row.id, d || '(unnamed)'); } },
|
|
{ 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'); };
|
|
// The news screen's tooltip is the slot's own world, whatever world the property is on
|
|
var tooltipNote = function (s, location) {
|
|
return String(location) === String(s.map_id) ? '' : 'In game the slot\'s tooltip still names ' + s.zone_name + '.';
|
|
};
|
|
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; }
|
|
var worldOptions = function (selected) {
|
|
return (state.worlds || []).map(function (w) {
|
|
return '<option value="' + w.map_id + '"' + (String(w.map_id) === String(selected) ? ' selected' : '') + '>' + esc(w.zone_name) + '</option>';
|
|
}).join('');
|
|
};
|
|
slotsEl.innerHTML = slots.map(function (s) {
|
|
var showing = s.showing
|
|
? fmt.link('/properties/' + s.showing.id, s.showing.name || '(unnamed)') + ' <span class="text-body-secondary">by ' + esc(s.showing.owner_name) +
|
|
(String(s.showing.zone_id) !== String(s.map_id) ? ', on ' + esc(s.showing.zone_name) : '') + '</span>'
|
|
: '<span class="text-body-secondary">Nothing (locked slot)</span>';
|
|
var showingNote = s.showing ? tooltipNote(s, s.showing.zone_id) : '';
|
|
var warn = fullAuto ? '' : s.picked_unavailable ? '<div class="small text-warning mt-1">The picked property is no longer approved and public in ' + esc(s.location_name) + ', 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 settings (' + esc(s.location_name) + ', ' + esc(s.mode) + ') are kept for when you switch back to per slot.</div>' :
|
|
'<label class="form-label small mb-1">Location</label>' +
|
|
'<select class="form-select form-select-sm mb-1" data-location aria-label="Location">' + worldOptions(s.location) + '</select>' +
|
|
'<div class="small text-body-secondary mb-2"><span data-count>' + countLabel(s.candidate_total) + '</span><span class="d-block text-info" data-tooltip-note>' + esc(tooltipNote(s, s.location)) + '</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>' +
|
|
(showingNote ? '<div class="small text-info">' + esc(showingNote) + '</div>' : '') + warn + (fullAuto ? '' : changed) +
|
|
'</div></div>';
|
|
}).join('');
|
|
if (fullAuto) return;
|
|
slots.forEach(function (s) {
|
|
var box = slotsEl.querySelector('[data-slot="' + s.template_id + '"]');
|
|
var select = SearchSelect(box.querySelector('[data-property]'), {
|
|
value: s.picked ? s.property_id : '',
|
|
label: s.picked ? propLabel(s.picked) : '',
|
|
search: function (text) {
|
|
var location = box.querySelector('[data-location]').value;
|
|
return api.get('/api/featured_properties/' + s.template_id + '/candidates?location=' + encodeURIComponent(location) + '&search=' + encodeURIComponent(text)).then(function (d) {
|
|
return (d.properties || []).map(function (p) { return { value: p.id, label: propLabel(p) }; });
|
|
});
|
|
}
|
|
});
|
|
// Another location: the pick has to be made there, and how many there are to pick from changes
|
|
box.querySelector('[data-location]').addEventListener('change', function (e) {
|
|
var location = e.target.value;
|
|
if (location === String(s.location) && s.picked) select.set(s.property_id, propLabel(s.picked));
|
|
else select.set('', '');
|
|
box.querySelector('[data-tooltip-note]').textContent = tooltipNote(s, location);
|
|
api.get('/api/featured_properties/' + s.template_id + '/candidates?location=' + encodeURIComponent(location)).then(function (d) {
|
|
if (box.querySelector('[data-location]').value !== location) return;
|
|
box.querySelector('[data-count]').textContent = countLabel(d.total || 0);
|
|
box.querySelector('[data-mode] option[value="picked"]').disabled = !d.total;
|
|
}).catch(function () {});
|
|
});
|
|
});
|
|
};
|
|
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, location: Number(box.querySelector('[data-location]').value) };
|
|
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 %}
|