mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
feat(dashboard): UGC server page
Counts, a paged list and failures of what the UGC server made of players' models and modular builds, making one, the failed ones or everything again (new ugc_manage permission), the UGC server's live status and icons from ugc_public_url, and a 3D view of a model's LXFML. The UGC settings are in the settings catalog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,7 @@
|
||||
#include "PublicRoutes.h"
|
||||
#include "Showcase.h"
|
||||
#include "ContrabandRoutes.h"
|
||||
#include "UgcRoutes.h"
|
||||
#include "PropertyRentRoutes.h"
|
||||
#include "FeaturedProperties.h"
|
||||
#include "PasswordRecovery.h"
|
||||
@@ -527,6 +528,7 @@ int main(int argc, char** argv) {
|
||||
RegisterShowcaseRoutes();
|
||||
FeaturedProperties::RegisterRoutes();
|
||||
ContrabandRoutes::RegisterRoutes();
|
||||
UgcRoutes::RegisterRoutes();
|
||||
PropertyRentRoutes::RegisterRoutes();
|
||||
RegisterPasswordRecoveryRoutes();
|
||||
RegisterWSRoutes();
|
||||
|
||||
@@ -52,6 +52,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"Showcase.cpp"
|
||||
"FeaturedProperties.cpp"
|
||||
"ContrabandRoutes.cpp"
|
||||
"UgcRoutes.cpp"
|
||||
"PropertyRentRoutes.cpp"
|
||||
"PasswordRecovery.cpp"
|
||||
"PrometheusMetrics.cpp"
|
||||
|
||||
@@ -295,6 +295,7 @@ namespace {
|
||||
c.Add(Port(DASHBOARD, "port", "Web port", "Where the dashboard listens for browsers.", "2006"));
|
||||
c.Add(Format(Text(DASHBOARD, "listen_ip", "Listen address", "127.0.0.1 for this machine only (put a reverse proxy in front); 0.0.0.0 for everyone.", "127.0.0.1", true), eFormat::HOST));
|
||||
c.Add(Port(DASHBOARD, "net_port", "Server link port", "UDP port for the connection to master (the next one is used too). Keep clear of other servers' ports.", "2010"));
|
||||
c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address", "Where the browser loads previews from the UGC server, e.g. https://ugc.example.com. Empty: this host, port 2008.", ""), eFormat::URL));
|
||||
c.Add(Format(Text(DASHBOARD, "dashboard_url", "Public address", "For links in emails and alerts, e.g. https://dashboard.example.com.", ""), eFormat::URL));
|
||||
c.Add(Bool(DASHBOARD, "secure_cookies", "HTTPS only cookies", "Turn on when the dashboard is served over HTTPS.", false));
|
||||
c.Add(Bool(DASHBOARD, "behind_proxy", "Behind a reverse proxy", "Use the proxy's X-Forwarded-For for rate limits. Only when the dashboard can't be reached directly.", false));
|
||||
|
||||
97
dDashboardServer/routes/UgcRoutes.cpp
Normal file
97
dDashboardServer/routes/UgcRoutes.cpp
Normal file
@@ -0,0 +1,97 @@
|
||||
#include "UgcRoutes.h"
|
||||
|
||||
#include "Database.h"
|
||||
#include "Game.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "RouteUtils.h"
|
||||
#include "Sd0.h"
|
||||
#include "WSRoutes.h"
|
||||
#include "dConfig.h"
|
||||
#include "eHTTPMethod.h"
|
||||
|
||||
using namespace RouteUtils;
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t PAGE_SIZE = 50;
|
||||
|
||||
const char* StateName(IUgc::eProcessState state) {
|
||||
switch (state) {
|
||||
case IUgc::eProcessState::PENDING: return "pending";
|
||||
case IUgc::eProcessState::DONE: return "done";
|
||||
case IUgc::eProcessState::FAILED: return "failed";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
std::optional<IUgc::eProcessState> ParseState(const std::string& text) {
|
||||
if (text == "pending") return IUgc::eProcessState::PENDING;
|
||||
if (text == "done") return IUgc::eProcessState::DONE;
|
||||
if (text == "failed") return IUgc::eProcessState::FAILED;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
nlohmann::json Counts(const std::vector<std::pair<IUgc::eProcessState, uint64_t>>& counts) {
|
||||
nlohmann::json out = { { "pending", 0 }, { "done", 0 }, { "failed", 0 } };
|
||||
for (const auto& [state, count] : counts) out[StateName(state)] = count;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
namespace UgcRoutes {
|
||||
void RegisterRoutes() {
|
||||
Route(eHTTPMethod::GET, "/ugc", Perm("properties_view"), "What the UGC server made of players' models",
|
||||
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "ugc.jinja2", "ugc"); });
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/ugc", Perm("properties_view"),
|
||||
"Processing state: {counts: {model, modular: {pending, done, failed}}, items: [{id, characterId, characterName, state, attempts, processedAt, "
|
||||
"error, bakeAo, modules}], ugcUrl, canManage}. Query: ?kind=model|modular&state=pending|done|failed&page=",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const bool modular = QueryValue(context.queryString, "kind") == "modular";
|
||||
const auto state = ParseState(QueryValue(context.queryString, "state"));
|
||||
const auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
|
||||
const auto list = modular ? Database::Get()->GetModularBuildProcessList(state, page * PAGE_SIZE, PAGE_SIZE + 1)
|
||||
: Database::Get()->GetUgcProcessList(state, page * PAGE_SIZE, PAGE_SIZE + 1);
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
for (size_t i = 0; i < list.size() && i < PAGE_SIZE; i++) {
|
||||
const auto& info = list[i];
|
||||
items.push_back({ { "id", std::to_string(info.id) }, { "characterId", std::to_string(info.characterId) }, { "characterName", info.characterName },
|
||||
{ "state", StateName(info.state) }, { "attempts", info.attempts }, { "processedAt", info.processedAt }, { "error", info.error },
|
||||
{ "bakeAo", info.bakeAo }, { "modules", info.details } });
|
||||
}
|
||||
JsonSuccess(reply, { { "counts", { { "model", Counts(Database::Get()->GetUgcProcessCounts()) }, { "modular", Counts(Database::Get()->GetModularBuildProcessCounts()) } } },
|
||||
{ "items", items }, { "more", list.size() > PAGE_SIZE }, { "ugcUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/ugc/:id/lxfml", Perm("properties_view"), "A player model's LXFML",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto id = PathId<LWOOBJID>(context.path, 2);
|
||||
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
|
||||
auto model = Database::Get()->GetUgcModel(*id);
|
||||
if (!model) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such model");
|
||||
Sd0 sd0(model->lxfmlData);
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.contentType = eContentType::TEXT_PLAIN;
|
||||
reply.message = sd0.GetAsStringUncompressed();
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/ugc/reprocess", Perm("ugc_manage"),
|
||||
"Have the UGC server make items again. Body: {kind: model|modular, id} for one, {kind, failedOnly: true} for the failed ones, {kind} for all",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto body = ParseBody(context);
|
||||
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
||||
const bool modular = body->value("kind", "model") == "modular";
|
||||
std::optional<LWOOBJID> id;
|
||||
if (body->contains("id")) {
|
||||
id = GeneralUtils::TryParse<LWOOBJID>((*body)["id"].is_string() ? (*body)["id"].get<std::string>() : (*body)["id"].dump());
|
||||
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
|
||||
}
|
||||
const bool failedOnly = body->value("failedOnly", false);
|
||||
const auto changed = modular ? Database::Get()->ResetModularBuildProcessing(id, failedOnly) : Database::Get()->ResetUgcModelProcessing(id, failedOnly);
|
||||
const std::string what = modular ? "modular build" : "model";
|
||||
Audit(context, "ugc_reprocess", "Queued " + std::to_string(changed) + " " + what + "(s) to be made again" +
|
||||
(id ? " (" + std::to_string(*id) + ")" : failedOnly ? " (the failed ones)" : " (all)"));
|
||||
BroadcastTableChanged("ugc");
|
||||
JsonSuccess(reply, { { "message", std::to_string(changed) + " " + what + (changed == 1 ? "" : "s") + " will be made again" } });
|
||||
});
|
||||
}
|
||||
}
|
||||
6
dDashboardServer/routes/UgcRoutes.h
Normal file
6
dDashboardServer/routes/UgcRoutes.h
Normal file
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
// The UGC page: what the UGC server has made of players' models and modular builds, and making them again
|
||||
namespace UgcRoutes {
|
||||
void RegisterRoutes();
|
||||
}
|
||||
118
dDashboardServer/static/js/ugc.js
Normal file
118
dDashboardServer/static/js/ugc.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* The UGC Server page: counts and a list of what the UGC server made of players' models and modular builds (from the
|
||||
* database), its live status and previews (from the UGC server itself, at ugc_public_url), and making things again.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var kind = 'model', state = '', page = 0, ugcUrl = '', canManage = false, viewer = null;
|
||||
var STATES = { pending: ['Waiting', 'secondary'], done: ['Made', 'success'], failed: ['Failed', 'danger'] };
|
||||
|
||||
function serverUrl() {
|
||||
return (ugcUrl || (location.protocol + '//' + location.hostname + ':2008')).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function fileUrl(id, name) {
|
||||
return serverUrl() + '/files/' + kind + '/' + encodeURIComponent(id) + '/' + name;
|
||||
}
|
||||
|
||||
function countCard(title, c) {
|
||||
return '<div class="col-md-6"><div class="card"><div class="card-body py-2"><div class="small text-body-secondary">' + esc(title) + '</div>' +
|
||||
fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') +
|
||||
'</div></div></div>';
|
||||
}
|
||||
|
||||
function row(i) {
|
||||
var s = STATES[i.state] || [i.state, 'secondary'];
|
||||
var icon = i.state === 'done' ? '<img src="' + esc(fileUrl(i.id, 'icon.png')) + '" width="48" height="48" loading="lazy" alt="" onerror="this.style.visibility=\'hidden\'">' : '';
|
||||
var owner = i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
|
||||
var details = kind === 'modular' ? '<code class="small">' + esc(i.modules) + '</code>' : '';
|
||||
if (i.error) details += '<div class="small text-danger">' + esc(i.error) + '</div>';
|
||||
var buttons = (kind === 'model' ? '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>' : '') +
|
||||
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.id) + '">Make again</button>' : '');
|
||||
return '<tr><td>' + icon + '</td><td class="small">' + esc(i.id) + '</td><td>' + owner + '</td><td>' + fmt.badge(s[0], s[1]) +
|
||||
(i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + '</td>' +
|
||||
'<td class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</td><td>' + details + '</td><td class="text-end text-nowrap">' + buttons + '</td></tr>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
api.get('/api/ugc?kind=' + kind + '&state=' + state + '&page=' + page).then(function (d) {
|
||||
if (!d.success) return;
|
||||
ugcUrl = d.ugcUrl;
|
||||
canManage = d.canManage;
|
||||
document.getElementById('manageButtons').classList.toggle('d-none', !canManage);
|
||||
document.getElementById('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets', d.counts.modular);
|
||||
document.getElementById('rows').innerHTML = d.items.map(row).join('') || '<tr><td colspan="7" class="text-body-secondary">Nothing here.</td></tr>';
|
||||
document.getElementById('prevPage').disabled = page === 0;
|
||||
document.getElementById('nextPage').disabled = !d.more;
|
||||
loadStatus();
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
var box = document.getElementById('serverStatus');
|
||||
fetch(serverUrl() + '/status').then(function (r) { return r.json(); }).then(function (s) {
|
||||
var mb = function (b) { return (b / 1048576).toFixed(0) + ' MB'; };
|
||||
var last = s.recent && s.recent[0];
|
||||
box.innerHTML = fmt.badge('Online', 'success') + ' ' + esc(s.workers) + ' workers, ' + esc(s.active) + ' working, ' + esc(s.queued) + ' queued. ' +
|
||||
'Since it started: ' + esc(s.made) + ' made, ' + esc(s.failed) + ' failed attempts. Files: ' + esc(mb(s.storedBytes)) +
|
||||
(s.maxStorageBytes ? ' of ' + esc(mb(s.maxStorageBytes)) : '') + (s.evicted ? ', ' + esc(s.evicted) + ' deleted to save space' : '') + '.' +
|
||||
(last ? '<div class="text-body-secondary">Last: ' + esc(last.kind) + ' ' + esc(last.id) + (last.ok ? ' made in ' + esc(last.ms) + ' ms' : ' failed') +
|
||||
(last.message ? ' (' + esc(last.message) + ')' : '') + '</div>' : '');
|
||||
}).catch(function () {
|
||||
box.innerHTML = fmt.badge('Unreachable', 'warning') + ' The UGC server doesn\'t answer at ' + esc(serverUrl()) +
|
||||
'. Is <code>enable_ugc_server</code> on, and <code>ugc_public_url</code> right?';
|
||||
});
|
||||
}
|
||||
|
||||
function remake(body, question) {
|
||||
if (question && !confirm(question)) return;
|
||||
body.kind = kind;
|
||||
api.post('/api/ugc/reprocess', body).then(function (d) {
|
||||
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
|
||||
toast(d.message, 'success');
|
||||
load();
|
||||
});
|
||||
}
|
||||
|
||||
function preview(id) {
|
||||
document.getElementById('previewTitle').textContent = 'Model ' + id;
|
||||
document.getElementById('previewIcon').src = fileUrl(id, 'icon.png');
|
||||
document.getElementById('previewLinks').innerHTML = '<a href="' + esc(fileUrl(id, 'model.nif')) + '">Download the mesh (.nif)</a><br>' +
|
||||
'<a href="/api/ugc/' + esc(id) + '/lxfml" download="ugc_' + esc(id) + '.lxfml">Download the LXFML</a>';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewModal')).show();
|
||||
var container = document.getElementById('previewViewer');
|
||||
container.textContent = 'Loading…';
|
||||
import('/js/lddviewer.js').then(function (module) {
|
||||
if (viewer && viewer.dispose) viewer.dispose();
|
||||
container.textContent = '';
|
||||
viewer = module.createViewer(container, {});
|
||||
return viewer.load([{ id: id, name: 'Model ' + id, position: [0, 0, 0], rotation: [0, 0, 0, 1], url: '/api/ugc/' + id + '/lxfml' }], 1);
|
||||
}).catch(function (e) { container.textContent = 'The 3D view could not load: ' + e.message; });
|
||||
}
|
||||
|
||||
document.getElementById('kindButtons').addEventListener('click', function (e) {
|
||||
var button = e.target.closest('[data-kind]');
|
||||
if (!button) return;
|
||||
kind = button.dataset.kind;
|
||||
page = 0;
|
||||
this.querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b === button); });
|
||||
load();
|
||||
});
|
||||
document.getElementById('stateFilter').addEventListener('change', function () { state = this.value; page = 0; load(); });
|
||||
document.getElementById('prevPage').addEventListener('click', function () { if (page > 0) { page--; load(); } });
|
||||
document.getElementById('nextPage').addEventListener('click', function () { page++; load(); });
|
||||
document.getElementById('retryFailed').addEventListener('click', function () { remake({ failedOnly: true }); });
|
||||
document.getElementById('remakeAll').addEventListener('click', function () {
|
||||
remake({}, 'Make every ' + (kind === 'model' ? 'model' : 'car and rocket') + ' again? This can take a long time.');
|
||||
});
|
||||
document.getElementById('rows').addEventListener('click', function (e) {
|
||||
var remakeButton = e.target.closest('[data-remake]'), previewButton = e.target.closest('[data-preview]');
|
||||
if (remakeButton) remake({ id: remakeButton.dataset.remake });
|
||||
else if (previewButton) preview(previewButton.dataset.preview);
|
||||
});
|
||||
if (window.Live) Live.on('ugc', load);
|
||||
|
||||
load();
|
||||
setInterval(loadStatus, 10000);
|
||||
})();
|
||||
@@ -117,8 +117,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "maintenance", "webhooks", "client_assets"] %}
|
||||
{% if can.settings or can.permissions_manage or can.tasks_view or can.play_keys_manage or can.backups or can.maintenance or can.webhooks or can.client_files %}
|
||||
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "maintenance", "webhooks", "client_assets", "ugc"] %}
|
||||
{% if can.settings or can.permissions_manage or can.tasks_view or can.play_keys_manage or can.backups or can.maintenance or can.webhooks or can.client_files or can.ugc_manage %}
|
||||
<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>
|
||||
@@ -132,6 +132,7 @@
|
||||
{% 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.maintenance %}<a href="/maintenance" class="list-group-item list-group-item-action{% if current_page == "maintenance" %} active{% endif %}">Maintenance</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>
|
||||
|
||||
77
dDashboardServer/templates/ugc.jinja2
Normal file
77
dDashboardServer/templates/ugc.jinja2
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}UGC Server - DarkflameServer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="ugcPage">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3" id="counts"></div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2 small" id="serverStatus">Asking the UGC server…</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
|
||||
<div class="btn-group btn-group-sm" role="group" id="kindButtons">
|
||||
<button type="button" class="btn btn-outline-primary active" data-kind="model">Models</button>
|
||||
<button type="button" class="btn btn-outline-primary" data-kind="modular">Cars and rockets</button>
|
||||
</div>
|
||||
<select class="form-select form-select-sm w-auto" id="stateFilter">
|
||||
<option value="">Every state</option><option value="pending">Waiting</option><option value="done">Made</option><option value="failed">Failed</option>
|
||||
</select>
|
||||
<div class="ms-auto d-none" id="manageButtons">
|
||||
<button type="button" class="btn btn-sm btn-outline-warning" id="retryFailed">Try the failed ones again</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="remakeAll">Make everything again</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="table table-sm align-middle mb-0 table-stack">
|
||||
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Last attempt</th><th>Details</th><th></th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="prevPage">Newer</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="nextPage">Older</button>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="previewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl"><div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title" id="previewTitle">Model</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3 text-center"><img id="previewIcon" alt="Icon" class="border rounded bg-body-tertiary" width="128" height="128">
|
||||
<div class="small mt-2" id="previewLinks"></div></div>
|
||||
<div class="col-md-9"><div id="previewViewer" style="height: 420px"></div>
|
||||
<div class="small text-body-secondary">The model as built (its LXFML).</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
|
||||
},
|
||||
"integrity": {
|
||||
"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js": "sha384-IDC7sAMAIMB/TZ6dgKKPPAKZ2bXXXP8+FBMBC8cU319eBhKITx+PaalhfDkDNH28",
|
||||
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js": "sha384-aJoe4qqS/DgF2jh9njAuvA6QIveJYoCuYOfYjdFY8P3eawzmc9bEQ40jq2TvOyuP",
|
||||
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js": "sha384-9ItPPH2vgKbkvgrqTKq7ptGYe+WuAnqlZtlqleJW4/CJjnM5R7C+efD6iS7ADPJ9"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/js/ldd-materials.js"></script>
|
||||
<script src="/js/ugc.js"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user