Files
red-valley/docs/nui_simulator.html
2026-03-29 21:41:17 +03:00

843 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Red Valley — NUI Simulator</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0a0b0d;
--bg-secondary: #111318;
--bg-tertiary: #181a1f;
--bg-card: #1a1c22;
--accent: #ff1a35;
--accent-glow: rgba(255, 26, 53, 0.15);
--text-primary: #ffffff;
--text-secondary: #8b8d94;
--text-muted: #555760;
--border: #222430;
--border-hover: #333540;
--success: #22c55e;
--warning: #f59e0b;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow: hidden;
}
/* Layout */
.app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 320px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h1 {
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-header h1 .badge {
font-size: 11px;
font-weight: 600;
background: var(--accent);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.5px;
}
.sidebar-header p {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.search-box {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
.search-box input {
width: 100%;
height: 36px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-family: 'Inter', sans-serif;
padding: 0 12px 0 36px;
outline: none;
transition: border-color 0.2s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23555760' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 10px center;
}
.search-box input:focus {
border-color: var(--accent);
}
.search-box input::placeholder {
color: var(--text-muted);
}
.nui-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.nui-list::-webkit-scrollbar {
width: 4px;
}
.nui-list::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.nui-list::-webkit-scrollbar-track {
background: transparent;
}
.nui-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 2px;
}
.nui-item:hover {
background: var(--bg-tertiary);
}
.nui-item.active {
background: var(--accent-glow);
border: 1px solid rgba(255, 26, 53, 0.3);
}
.nui-item .icon {
width: 34px;
height: 34px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.nui-item.active .icon {
background: var(--accent);
border-color: var(--accent);
}
.nui-item .info {
flex: 1;
min-width: 0;
}
.nui-item .info .name {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nui-item .info .path {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nui-item .tag {
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 3px;
flex-shrink: 0;
}
.tag-custom { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
.tag-vendor { background: rgba(96, 165, 250, 0.15); color: #60a5fa; }
.tag-core { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
/* Main content */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.toolbar {
height: 50px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
gap: 8px;
}
.toolbar .btn {
height: 32px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
font-family: 'Inter', sans-serif;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.15s;
}
.toolbar .btn:hover {
background: var(--bg-card);
color: var(--text-primary);
border-color: var(--border-hover);
}
.toolbar .btn-accent {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.toolbar .btn-accent:hover {
opacity: 0.85;
}
.toolbar .resolution {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.toolbar .resolution select {
height: 32px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: 'Inter', sans-serif;
padding: 0 8px;
outline: none;
cursor: pointer;
}
.toolbar .resolution label {
font-size: 11px;
color: var(--text-muted);
}
/* Preview area */
.preview-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at 20% 50%, rgba(255, 26, 53, 0.03) 0%, transparent 50%),
repeating-linear-gradient(0deg, transparent, transparent 19px, rgba(255,255,255,0.02) 19px, rgba(255,255,255,0.02) 20px),
repeating-linear-gradient(90deg, transparent, transparent 19px, rgba(255,255,255,0.02) 19px, rgba(255,255,255,0.02) 20px),
var(--bg-primary);
padding: 20px;
position: relative;
}
.preview-frame {
width: 1280px;
height: 720px;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: #000;
position: relative;
box-shadow: 0 0 60px rgba(0, 0, 0, 0.5);
transform-origin: center center;
}
.preview-frame iframe {
width: 100%;
height: 100%;
border: none;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
}
.empty-state .icon-large {
width: 64px;
height: 64px;
border-radius: 16px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
border: 1px solid var(--border);
}
.empty-state h3 {
font-size: 16px;
font-weight: 600;
}
.empty-state p {
font-size: 13px;
color: var(--text-muted);
text-align: center;
max-width: 300px;
}
/* Status bar */
.status-bar {
height: 30px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
font-size: 11px;
color: var(--text-muted);
gap: 16px;
}
.status-bar .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
}
.status-bar .item {
display: flex;
align-items: center;
gap: 5px;
}
/* Simulation Panel */
.sim-panel {
width: 280px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sim-panel-header {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
}
.sim-panel-header h3 {
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
}
.sim-panel-header p {
font-size: 11px;
color: var(--text-muted);
margin-top: 3px;
}
.sim-section {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.sim-section h4 {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.sim-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.sim-toggle label {
font-size: 12px;
font-weight: 500;
}
.toggle-switch {
position: relative;
width: 40px;
height: 22px;
cursor: pointer;
}
.toggle-switch input {
display: none;
}
.toggle-slider {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: #333;
border-radius: 11px;
transition: 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 3px;
top: 3px;
background: white;
border-radius: 50%;
transition: 0.2s;
}
.toggle-switch input:checked + .toggle-slider {
background: var(--success);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(18px);
}
.sim-job-btn {
width: 100%;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
font-weight: 500;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.sim-job-btn:hover {
background: var(--bg-card);
border-color: var(--border-hover);
}
.sim-job-btn .req {
margin-left: auto;
font-size: 10px;
color: var(--accent);
font-weight: 600;
}
.sim-log {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.sim-log h4 {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.sim-log::-webkit-scrollbar {
width: 4px;
}
.sim-log::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.log-entry {
font-size: 11px;
padding: 4px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
line-height: 1.5;
}
.log-entry .time {
color: var(--text-muted);
font-size: 10px;
}
.log-success { color: var(--success); }
.log-error { color: var(--accent); }
.log-info { color: #60a5fa; }
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h1>🎮 NUI Simulator <span class="badge">RV</span></h1>
<p>Test NUI resources without launching FiveM</p>
</div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Search resources...">
</div>
<div class="nui-list" id="nuiList"></div>
</div>
<!-- Main -->
<div class="main">
<div class="toolbar">
<button class="btn" id="btnRefresh" title="Reload NUI">↻ Reload</button>
<button class="btn" id="btnSendShow" title="Send show message">📤 Send {show}</button>
<button class="btn" id="btnSendHide" title="Send hide message">📥 Send {hide}</button>
<button class="btn" id="btnDevTools" title="Open in new tab">🔗 Open Tab</button>
<div class="resolution">
<label>Resolution:</label>
<select id="resolutionSelect">
<option value="1920x1080">1920×1080</option>
<option value="1280x720" selected>1280×720</option>
<option value="1600x900">1600×900</option>
<option value="2560x1440">2560×1440</option>
</select>
</div>
</div>
<div class="preview-container">
<div class="preview-frame" id="previewFrame">
<div class="empty-state" id="emptyState">
<div class="icon-large">🖥️</div>
<h3>No NUI Selected</h3>
<p>Select a resource from the sidebar to preview its NUI interface</p>
</div>
</div>
</div>
<div class="status-bar">
<div class="item">
<span class="dot"></span>
Ready
</div>
<div class="item" id="statusResource">No resource loaded</div>
<div class="item" id="statusResolution">1280×720</div>
</div>
</div>
<!-- Simulation Panel -->
<div class="sim-panel">
<div class="sim-panel-header">
<h3>🧪 Flow Simulator</h3>
<p>Simulează logica server-side</p>
</div>
<div class="sim-section">
<h4>👤 Player State</h4>
<div class="sim-toggle">
<label>🪪 Permis de conducere</label>
<div class="toggle-switch">
<input type="checkbox" id="toggleLicense">
<span class="toggle-slider"></span>
</div>
</div>
<div class="sim-toggle">
<label>🆔 Buletin (ID Card)</label>
<div class="toggle-switch">
<input type="checkbox" id="toggleIdCard" checked>
<span class="toggle-slider"></span>
</div>
</div>
</div>
<div class="sim-section">
<h4>💼 Job Center — Apply</h4>
<button class="sim-job-btn" data-job="garbage" onclick="simApply('garbage')">🗑️ Garbage Collector <span class="req">🚗 permis</span></button>
<button class="sim-job-btn" data-job="deliverer" onclick="simApply('deliverer')">📦 Deliverer <span class="req">🚗 permis</span></button>
<button class="sim-job-btn" data-job="postman" onclick="simApply('postman')">✉️ Postman <span class="req">🚗 permis</span></button>
<button class="sim-job-btn" data-job="lumberjack" onclick="simApply('lumberjack')">🪓 Lumberjack <span class="req">🚗 permis</span></button>
<button class="sim-job-btn" data-job="bus" onclick="simApply('bus')">🚌 Bus Driver <span class="req">🚗 permis</span></button>
<button class="sim-job-btn" data-job="miner" onclick="simApply('miner')">⛏️ Miner</button>
<button class="sim-job-btn" data-job="electrician" onclick="simApply('electrician')">⚡ Electrician</button>
</div>
<div class="sim-log" id="simLog">
<h4>📋 Server Log</h4>
</div>
</div>
</div>
<script>
// NUI Registry - all NUI pages on the server
const nuiResources = [
{ name: "⭐ Job Center (Simulator)", path: "__mock__/jobcenter", tag: "custom", icon: "💼", mock: "../docs/mock_jobcenter.html" },
{ name: "rv-license-dialog", path: "[framework]/[addons]/rv-license-dialog/html/index.html", tag: "custom", icon: "🔒" },
{ name: "17mov_JobCenter", path: "[framework]/[base]/[jobs]/17mov_JobCenter/web/index.html", tag: "vendor", icon: "💼" },
{ name: "17mov_CharacterSystem", path: "[framework]/[base]/[auth]/17mov_CharacterSystem/web/index.html", tag: "vendor", icon: "👤" },
{ name: "17mov_Hud", path: "[framework]/[base]/[ui]/17mov_Hud/web/index.html", tag: "vendor", icon: "📊" },
{ name: "17mov_Lumberjack", path: "[framework]/[base]/[jobs]/[citizen]/17mov_Lumberjack/web/index.html", tag: "vendor", icon: "🪓" },
{ name: "17mov_Miner", path: "[framework]/[base]/[jobs]/[citizen]/17mov_Miner/web/index.html", tag: "vendor", icon: "⛏️" },
{ name: "17mov_OilRig", path: "[framework]/[base]/[jobs]/[citizen]/17mov_OilRig/web/index.html", tag: "vendor", icon: "🛢️" },
{ name: "luxu_admin", path: "luxu_admin/web/index.html", tag: "vendor", icon: "⚙️" },
{ name: "ac-carcontrol", path: "[framework]/[addons]/ac-carcontrol/html/index.html", tag: "vendor", icon: "🚗" },
{ name: "ak4y-dice", path: "[framework]/[addons]/ak4y-dice/html/index.html", tag: "vendor", icon: "🎲" },
{ name: "bit-driverschool", path: "[framework]/[addons]/bit-driverschool/html/index.html", tag: "vendor", icon: "🏫" },
{ name: "kq_animsuggest", path: "[framework]/[addons]/kq_animsuggest/nui/index.html", tag: "vendor", icon: "💃" },
{ name: "kq_dyno", path: "[framework]/[addons]/kq_dyno/html/index.html", tag: "vendor", icon: "📈" },
{ name: "qb-input", path: "[framework]/[addons]/qb-input/html/index.html", tag: "core", icon: "📝" },
{ name: "qs-smartphone-pro", path: "[framework]/[addons]/qs-smartphone-pro/html/index.html", tag: "vendor", icon: "📱" },
{ name: "svdden_banking", path: "[framework]/[addons]/svdden_banking/html/index.html", tag: "vendor", icon: "🏦" },
{ name: "rcore_casino", path: "[framework]/[addons]/[casino]/rcore_casino/client/html/index.html", tag: "vendor", icon: "🎰" },
{ name: "qs-notify", path: "[framework]/[addons]/[notify]/qs-notify/html/index.html", tag: "vendor", icon: "🔔" },
{ name: "mBossmenu", path: "[framework]/[base]/[jobs]/mBossmenu/html/index.html", tag: "vendor", icon: "👔" },
{ name: "wasabi_ambulance", path: "[framework]/[base]/[jobs]/[legal]/[ambulance]/wasabi_ambulance/nui/index.html", tag: "vendor", icon: "🚑" },
{ name: "t1ger_mechanic", path: "[framework]/[base]/[jobs]/[legal]/[mechanic]/t1ger_mechanic/web/index.html", tag: "vendor", icon: "🔧" },
{ name: "codem-dispatch", path: "[framework]/[base]/[jobs]/[legal]/[police]/codem-dispatch/nui/index.html", tag: "vendor", icon: "📻" },
{ name: "t1ger_tuningsystem", path: "[framework]/[base]/[jobs]/[legal]/[tuner]/t1ger_tuningsystem/web/index.html", tag: "vendor", icon: "🏎️" },
{ name: "qb-core", path: "[framework]/[core]/qb-core/html/index.html", tag: "core", icon: "🧩" },
{ name: "qb-menu", path: "[framework]/[core]/qb-menu/html/index.html", tag: "core", icon: "📋" },
{ name: "qb-target", path: "[framework]/[core]/qb-target/html/index.html", tag: "core", icon: "🎯" },
{ name: "interact-sound", path: "[framework]/[depends]/interact-sound/client/html/index.html", tag: "core", icon: "🔊" },
{ name: "phone-radio", path: "[framework]/[depends]/phone-radio/html/index.html", tag: "core", icon: "📻" },
{ name: "progressbar", path: "[framework]/[depends]/progressbar/html/index.html", tag: "core", icon: "⏳" },
{ name: "xsound", path: "[framework]/[depends]/xsound/html/index.html", tag: "core", icon: "🎵" },
];
const BASE_PATH = "../resources/";
let activeResource = null;
// Render sidebar
function renderList(filter = "") {
const list = document.getElementById("nuiList");
const filtered = nuiResources.filter(r =>
r.name.toLowerCase().includes(filter.toLowerCase()) ||
r.path.toLowerCase().includes(filter.toLowerCase())
);
list.innerHTML = filtered.map(r => `
<div class="nui-item ${activeResource === r.name ? 'active' : ''}" data-name="${r.name}" data-path="${r.path}">
<div class="icon">${r.icon}</div>
<div class="info">
<div class="name">${r.name}</div>
<div class="path">${r.path}</div>
</div>
<span class="tag tag-${r.tag}">${r.tag}</span>
</div>
`).join("");
// Click handlers
list.querySelectorAll(".nui-item").forEach(item => {
item.addEventListener("click", () => loadNUI(item.dataset.name, item.dataset.path));
});
}
// Load NUI
function loadNUI(name, path) {
activeResource = name;
const frame = document.getElementById("previewFrame");
const empty = document.getElementById("emptyState");
if (empty) empty.remove();
// Remove existing iframe
const existingIframe = frame.querySelector("iframe");
if (existingIframe) existingIframe.remove();
// Check if this is a mock resource
const resource = nuiResources.find(r => r.name === name);
const src = resource && resource.mock ? resource.mock : BASE_PATH + path;
// Create new iframe
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.id = "nuiIframe";
frame.appendChild(iframe);
// Update status
document.getElementById("statusResource").textContent = `📦 ${name}`;
renderList(document.getElementById("searchInput").value);
}
// Send NUI message to iframe
function sendMessage(data) {
const iframe = document.getElementById("nuiIframe");
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(data, "*");
}
}
// Resize preview
function updateResolution() {
const [w, h] = document.getElementById("resolutionSelect").value.split("x").map(Number);
const frame = document.getElementById("previewFrame");
const container = document.querySelector(".preview-container");
frame.style.width = w + "px";
frame.style.height = h + "px";
// Scale to fit
const maxW = container.clientWidth - 40;
const maxH = container.clientHeight - 40;
const scale = Math.min(maxW / w, maxH / h, 1);
frame.style.transform = `scale(${scale})`;
document.getElementById("statusResolution").textContent = `${w}×${h} (${Math.round(scale * 100)}%)`;
}
// Event listeners
document.getElementById("searchInput").addEventListener("input", e => renderList(e.target.value));
document.getElementById("btnRefresh").addEventListener("click", () => {
const iframe = document.getElementById("nuiIframe");
if (iframe) iframe.src = iframe.src;
});
document.getElementById("btnSendShow").addEventListener("click", () => {
sendMessage({ action: "show" });
sendMessage({ type: "open" });
sendMessage({ show: true });
});
document.getElementById("btnSendHide").addEventListener("click", () => {
sendMessage({ action: "hide" });
sendMessage({ type: "close" });
sendMessage({ show: false });
});
document.getElementById("btnDevTools").addEventListener("click", () => {
if (activeResource) {
const r = nuiResources.find(n => n.name === activeResource);
if (r) window.open(BASE_PATH + r.path, "_blank");
}
});
document.getElementById("resolutionSelect").addEventListener("change", updateResolution);
window.addEventListener("resize", updateResolution);
// ========== FLOW SIMULATOR ==========
const licenseJobs = { garbage: true, deliverer: true, postman: true, lumberjack: true, bus: true };
function simApply(job) {
const hasLicense = document.getElementById('toggleLicense').checked;
const hasIdCard = document.getElementById('toggleIdCard').checked;
const needsLicense = licenseJobs[job] || false;
logSim('info', `Player apasă APPLY pe [${job}]`);
logSim('info', `→ Server: SetPlayerJob(src, "${job}", 0)`);
if (needsLicense) {
logSim('info', `→ Check: licenseJobs["${job}"] = true → verificare permis...`);
logSim('info', `→ metadata.licences.driver = ${hasLicense}`);
if (!hasLicense) {
logSim('error', `✖ BLOCAT: Nu are permis de conducere!`);
logSim('error', `→ TriggerClientEvent("QBCore:Notify", "error")`);
logSim('error', `→ TriggerClientEvent("rv:showLicenseDialog")`);
logSim('info', `→ return false (job NU a fost setat)`);
// Show the license dialog in preview
showLicenseDialog();
return;
}
}
logSim('success', `✔ metadata.licences.driver = true`);
logSim('success', `✔ player.Functions.SetJob("${job}", 0) → SUCCESS`);
logSim('success', `→ Player este acum: ${job}`);
}
function showLicenseDialog() {
// Load rv-license-dialog in preview if not already loaded
const iframe = document.getElementById('nuiIframe');
const licensePath = '[framework]/[addons]/rv-license-dialog/html/index.html';
if (!iframe || activeResource !== 'rv-license-dialog') {
// Load license dialog
loadNUI('rv-license-dialog', licensePath);
// Wait for iframe to load then send show
setTimeout(() => {
sendMessage({ action: 'show' });
}, 500);
} else {
sendMessage({ action: 'show' });
}
}
function logSim(type, msg) {
const log = document.getElementById('simLog');
const time = new Date().toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const entry = document.createElement('div');
entry.className = `log-entry log-${type}`;
entry.innerHTML = `<span class="time">[${time}]</span> ${msg}`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
// Init
renderList();
setTimeout(updateResolution, 100);
</script>
</body>
</html>