Add webtools static frontend and serve it via docker-compose

Adds an nginx:alpine service serving webtools/ (item/map lookup
frontend) on port 80 alongside the existing login/char/world stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ThomasandClaude Sonnet 5 committed 2026-07-09 06:35:50 +02:00
1 parent 42cf2944ba
commit 36857a2eab
5 files changed
+651

No files matched your search

+487
View File
@@ -0,0 +1,487 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KTRose GM Toolbox</title>
<style>
:root{
--bg:#12141a; --bg-raised:#181b23; --bg-card:#1d2029; --line:#2a2e3a;
--ink:#e9e6e2; --ink-dim:#9a99a8; --ink-faint:#65647a;
--thorn:#c2557a; --thorn-dim:#8a4560; --thorn-ink:#e28aa8;
--leaf:#7fb88a; --amber:#d1a054; --danger:#d16060;
--mono: ui-monospace,"Cascadia Code","SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--serif: Georgia,"Iowan Old Style","Palatino Linotype","Book Antiqua",serif;
--sans: -apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--radius:10px;
}
@media (prefers-color-scheme: light){
:root:not([data-theme="dark"]){
--bg:#f7f4ef; --bg-raised:#ffffff; --bg-card:#ffffff; --line:#e2ddd3;
--ink:#231d22; --ink-dim:#645d68; --ink-faint:#8e8790;
--thorn:#a83060; --thorn-dim:#c98aa2; --thorn-ink:#8a1f45;
--leaf:#3e7a4d; --amber:#8a611f; --danger:#a83a3a;
}
}
:root[data-theme="light"]{
--bg:#f7f4ef; --bg-raised:#ffffff; --bg-card:#ffffff; --line:#e2ddd3;
--ink:#231d22; --ink-dim:#645d68; --ink-faint:#8e8790;
--thorn:#a83060; --thorn-dim:#c98aa2; --thorn-ink:#8a1f45;
--leaf:#3e7a4d; --amber:#8a611f; --danger:#a83a3a;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
background:radial-gradient(1200px 600px at 15% -10%, color-mix(in srgb, var(--thorn) 10%, transparent), transparent) var(--bg);
color:var(--ink); font-family:var(--sans); -webkit-font-smoothing:antialiased;
}
a{color:inherit;}
::selection{background:color-mix(in srgb, var(--thorn) 35%, transparent);}
.shell{display:grid; grid-template-columns:260px 1fr; min-height:100vh;}
@media (max-width:860px){.shell{grid-template-columns:1fr;}}
.side{
border-right:1px solid var(--line); background:var(--bg-raised);
padding:26px 20px; position:sticky; top:0; height:100vh; overflow-y:auto;
display:flex; flex-direction:column; gap:20px;
}
@media (max-width:860px){.side{position:static; height:auto; border-right:none; border-bottom:1px solid var(--line);}}
.brand .mark{
font-family:var(--serif); font-size:1.4rem; font-style:italic;
background:linear-gradient(120deg, var(--thorn), var(--ink) 140%);
-webkit-background-clip:text; background-clip:text; color:transparent;
}
.brand .sub{font-size:0.7rem; letter-spacing:0.1em; text-transform:uppercase; color:var(--ink-faint); margin-top:2px;}
.tabs{display:flex; flex-direction:column; gap:3px;}
.tab{
display:flex; align-items:center; gap:9px; padding:9px 11px; border-radius:8px;
font-size:0.9rem; color:var(--ink-dim); cursor:pointer; border:1px solid transparent;
}
.tab:hover{background:var(--bg-card); color:var(--ink);}
.tab.active{background:var(--bg-card); border-color:var(--line); color:var(--ink); font-weight:600;}
.tab .ico{font-size:1rem; width:1.2em; text-align:center;}
.side-panel{display:none; flex-direction:column; gap:10px;}
.side-panel.active{display:flex;}
.side-panel input, .side-panel select{
width:100%; background:var(--bg-card); border:1px solid var(--line); color:var(--ink);
padding:9px 11px; border-radius:8px; font-size:0.87rem; font-family:var(--sans);
}
.side-panel input:focus, .side-panel select:focus{outline:2px solid var(--thorn); outline-offset:1px;}
.side-panel .label{font-size:0.68rem; letter-spacing:0.1em; text-transform:uppercase; color:var(--ink-faint); margin:6px 0 -2px;}
.navgroup{display:flex; flex-direction:column; gap:2px; max-height:340px; overflow-y:auto;}
.navlink{
display:flex; align-items:center; justify-content:space-between; padding:6px 10px;
border-radius:7px; font-size:0.83rem; color:var(--ink-dim); cursor:pointer; border:1px solid transparent;
}
.navlink:hover{background:var(--bg-card); color:var(--ink);}
.navlink.on{background:var(--bg-card); border-color:var(--line); color:var(--ink); font-weight:600;}
.navlink .count{font-variant-numeric:tabular-nums; font-size:0.7rem; color:var(--ink-faint);}
.legend{margin-top:auto; padding-top:14px; border-top:1px solid var(--line); font-size:0.7rem; color:var(--ink-faint); display:flex; flex-direction:column; gap:6px;}
.legend .row{display:flex; align-items:center; gap:8px;}
.pill{font-size:0.66rem; letter-spacing:0.06em; text-transform:uppercase; padding:2px 8px; border-radius:999px; font-weight:700; white-space:nowrap;}
.pill-gm{background:color-mix(in srgb, var(--leaf) 22%, var(--bg-card)); color:var(--leaf);}
.pill-dev{background:color-mix(in srgb, var(--amber) 25%, var(--bg-card)); color:var(--amber);}
.pill-any{background:color-mix(in srgb, var(--ink-faint) 20%, var(--bg-card)); color:var(--ink-dim);}
.main{padding:34px 42px 80px; max-width:1040px;}
@media (max-width:640px){.main{padding:22px 16px 60px;}}
.view{display:none;}
.view.active{display:block;}
.pagehead{margin-bottom:26px;}
.pagehead h1{font-family:var(--serif); font-style:italic; font-weight:400; font-size:1.9rem; margin:0 0 8px; text-wrap:balance;}
.pagehead p{margin:0; color:var(--ink-dim); font-size:0.93rem; max-width:66ch; line-height:1.55;}
.pagehead code{font-family:var(--mono); background:var(--bg-card); border:1px solid var(--line); padding:1px 6px; border-radius:5px; font-size:0.85em;}
.resultmeta{font-size:0.78rem; color:var(--ink-faint); margin:20px 0 10px;}
.category h2{
font-size:0.74rem; letter-spacing:0.11em; text-transform:uppercase; color:var(--thorn-dim);
margin:28px 0 12px; padding-bottom:8px; border-bottom:1px solid var(--line);
}
.cmdlist{display:flex; flex-direction:column; gap:9px;}
.cmd{background:var(--bg-card); border:1px solid var(--line); border-radius:var(--radius); padding:13px 15px; display:flex; flex-direction:column; gap:7px;}
.cmd-row{display:flex; align-items:baseline; gap:10px; flex-wrap:wrap;}
.cmd-syntax{font-family:var(--mono); font-size:0.9rem; font-weight:600;}
.cmd-syntax .arg{color:var(--ink-dim); font-weight:400;}
.copybtn{margin-left:auto; font-size:0.7rem; color:var(--ink-faint); border:1px solid var(--line); background:transparent; border-radius:6px; padding:3px 8px; cursor:pointer; font-family:var(--sans);}
.copybtn:hover{color:var(--ink); border-color:var(--ink-faint);}
.copybtn:focus-visible{outline:2px solid var(--thorn); outline-offset:1px;}
.cmd-desc{font-size:0.86rem; color:var(--ink-dim); line-height:1.5; max-width:70ch;}
.cmd-example{font-family:var(--mono); font-size:0.79rem; color:var(--thorn-ink); background:color-mix(in srgb, var(--thorn) 10%, transparent); border-left:2px solid var(--thorn); padding:6px 10px; border-radius:0 6px 6px 0; width:fit-content;}
/* Items */
.item-toolbar{display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:16px;}
.item-toolbar select, .item-toolbar input{
background:var(--bg-card); border:1px solid var(--line); color:var(--ink); padding:8px 10px; border-radius:8px; font-size:0.85rem;
}
.item-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:8px;}
.item-card{
background:var(--bg-card); border:1px solid var(--line); border-radius:9px; padding:10px 12px;
cursor:pointer; display:flex; flex-direction:column; gap:3px;
}
.item-card:hover{border-color:var(--thorn-dim);}
.item-card .nm{font-size:0.87rem; font-weight:600;}
.item-card .id{font-family:var(--mono); font-size:0.72rem; color:var(--ink-faint);}
.item-card.sel{border-color:var(--thorn); background:color-mix(in srgb, var(--thorn) 8%, var(--bg-card));}
.configurator{
position:sticky; bottom:0; margin-top:20px; background:var(--bg-raised); border:1px solid var(--line);
border-radius:var(--radius); padding:16px 18px; box-shadow:0 -8px 24px -12px rgba(0,0,0,0.4);
}
.configurator h3{margin:0 0 10px; font-size:0.95rem; font-family:var(--serif); font-style:italic; font-weight:400;}
.cfg-grid{display:grid; grid-template-columns:repeat(6,1fr); gap:10px;}
@media (max-width:760px){.cfg-grid{grid-template-columns:repeat(2,1fr);}}
.cfg-field label{display:block; font-size:0.66rem; letter-spacing:0.08em; text-transform:uppercase; color:var(--ink-faint); margin-bottom:4px;}
.cfg-field input, .cfg-field select{width:100%; background:var(--bg-card); border:1px solid var(--line); color:var(--ink); padding:7px 9px; border-radius:7px; font-size:0.85rem;}
.cfg-out{display:flex; align-items:center; gap:10px; margin-top:14px; flex-wrap:wrap;}
.cfg-out code{font-family:var(--mono); background:var(--bg-card); border:1px solid var(--line); padding:7px 12px; border-radius:7px; font-size:0.86rem; color:var(--thorn-ink); flex:1; min-width:200px;}
.cfg-out button{background:var(--thorn); color:#fff; border:none; padding:8px 16px; border-radius:7px; font-size:0.85rem; cursor:pointer; font-weight:600;}
.cfg-out button:hover{filter:brightness(1.08);}
.cfg-empty{color:var(--ink-faint); font-size:0.85rem; padding:8px 0;}
/* Maps */
.map-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:12px;}
.map-card{background:var(--bg-card); border:1px solid var(--line); border-radius:var(--radius); padding:16px;}
.map-card h3{margin:0 0 4px; font-size:1.1rem; font-family:var(--mono);}
.map-card .note{font-size:0.78rem; color:var(--thorn-dim); margin:4px 0 8px;}
.map-stats{display:grid; grid-template-columns:1fr 1fr; gap:6px 14px; font-size:0.82rem; color:var(--ink-dim);}
.map-stats b{color:var(--ink); font-variant-numeric:tabular-nums;}
.map-empty-note{
background:color-mix(in srgb, var(--amber) 12%, var(--bg-card)); border:1px solid color-mix(in srgb, var(--amber) 35%, var(--line));
border-radius:var(--radius); padding:14px 16px; font-size:0.85rem; color:var(--ink-dim); margin-bottom:20px; line-height:1.5;
}
.empty{padding:50px 20px; text-align:center; color:var(--ink-faint); font-size:0.9rem;}
</style>
</head>
<body>
<div class="shell">
<aside class="side">
<div class="brand">
<div class="mark">KTRose Toolbox</div>
<div class="sub">self-hosted · no auth</div>
</div>
<nav class="tabs">
<div class="tab active" data-view="commands"><span class="ico">⌘</span> GM Commands</div>
<div class="tab" data-view="items"><span class="ico">◆</span> Item Configurator</div>
<div class="tab" data-view="maps"><span class="ico">⌗</span> Maps</div>
</nav>
<div class="side-panel active" id="panel-commands">
<div class="label">Search</div>
<input id="q-commands" type="text" placeholder="Search commands…" autocomplete="off" />
<div class="navgroup" id="nav-commands"></div>
</div>
<div class="side-panel" id="panel-items">
<div class="label">Category</div>
<select id="item-cat"></select>
<div class="label">Search by name or id</div>
<input id="q-items" type="text" placeholder="e.g. wooden sword, or 42" autocomplete="off" />
</div>
<div class="side-panel" id="panel-maps">
<div class="label">Sort</div>
<select id="map-sort">
<option value="id">Map ID</option>
<option value="monsters">Monster count</option>
<option value="npcs">NPC count</option>
</select>
</div>
<div class="legend" id="legend-commands">
<div class="row"><span class="pill pill-any">any</span> usable by any GM character</div>
<div class="row"><span class="pill pill-gm">gm 300+</span> accesslevel ≥ 300</div>
<div class="row"><span class="pill pill-dev">dev 900+</span> accesslevel ≥ 900 + IsDev</div>
</div>
</aside>
<main class="main">
<!-- ===================== COMMANDS ===================== -->
<section class="view active" id="view-commands">
<div class="pagehead">
<h1>Every <code>/command</code> the world server understands</h1>
<p>Typed straight into normal chat, leading slash included — e.g. <code>/level 50 thomas</code>.
Pulled directly from <code>gmcmds.cpp</code>. Thomas is flagged accesslevel 900 + IsGM + IsDev,
so everything here is available to that character.</p>
</div>
<div class="resultmeta" id="resultmeta-commands"></div>
<div id="results-commands"></div>
</section>
<!-- ===================== ITEMS ===================== -->
<section class="view" id="view-items">
<div class="pagehead">
<h1>Pick an item, get the exact <code>/item</code> command</h1>
<p>9,951 items straight out of <code>itemlist.xls</code> (the same catalog the client uses), grouped into
the 14 categories the <code>/item</code> and <code>/give2</code> commands expect. Click one to configure
refine/count/etc. and copy the ready-to-paste command.</p>
</div>
<div class="item-toolbar">
<input id="give-to" type="text" placeholder="Leave empty for yourself, or type a charname for /give2" style="flex:1; min-width:240px;" />
</div>
<div class="resultmeta" id="resultmeta-items"></div>
<div class="item-grid" id="results-items"></div>
<div class="configurator" id="configurator" style="display:none;">
<h3 id="cfg-title"></h3>
<div class="cfg-grid">
<div class="cfg-field"><label>Count</label><input id="cfg-amount" type="number" value="1" min="1" /></div>
<div class="cfg-field"><label>Refine (0-9)</label><input id="cfg-refine" type="number" value="0" min="0" max="9" /></div>
<div class="cfg-field"><label>Lifespan</label><input id="cfg-life" type="number" value="100" min="0" /></div>
<div class="cfg-field"><label>Socketed</label><select id="cfg-socket"><option value="0">no</option><option value="1">yes</option></select></div>
<div class="cfg-field"><label>Stats</label><input id="cfg-stats" type="number" value="0" min="0" /></div>
</div>
<div class="cfg-out">
<code id="cfg-command"></code>
<button id="cfg-copy" type="button">Copy command</button>
</div>
</div>
</section>
<!-- ===================== MAPS ===================== -->
<section class="view" id="view-maps">
<div class="pagehead">
<h1>Every map with active spawn or NPC data</h1>
<p>Aggregated live from <code>list_special_spawns</code> and <code>list_npcs</code>.</p>
</div>
<div class="map-empty-note">
No minimap images or display names are shown here — this repo only ships the server side of KTRose, not the
client's 3D/texture assets, and the localized zone-name strings aren't parsed by the server's simplified STB
loader either. What's below (spawn counts, monster totals, coordinate bounds) is real, queried straight from
the running database.
</div>
<div class="map-grid" id="results-maps"></div>
</section>
</main>
</div>
<script src="commands-data.js"></script>
<script>
/* ---------------- shared helpers ---------------- */
function copyText(t){ navigator.clipboard.writeText(t).catch(()=>{}); }
/* ---------------- tab switching ---------------- */
document.querySelectorAll(".tab").forEach(tab=>{
tab.addEventListener("click", ()=>{
document.querySelectorAll(".tab").forEach(t=>t.classList.remove("active"));
document.querySelectorAll(".side-panel").forEach(p=>p.classList.remove("active"));
document.querySelectorAll(".view").forEach(v=>v.classList.remove("active"));
tab.classList.add("active");
const view = tab.dataset.view;
document.getElementById("panel-"+view).classList.add("active");
document.getElementById("view-"+view).classList.add("active");
document.getElementById("legend-commands").style.display = view==="commands" ? "flex" : "none";
});
});
/* ---------------- COMMANDS TAB ---------------- */
function levelClass(l){ if(l.startsWith("dev")) return "pill-dev"; if(l.startsWith("gm")) return "pill-gm"; return "pill-any"; }
function syntaxHTML(item){
let out = `<span>${item.cmd}</span>`;
item.args.forEach(a=>{ out += ` <span class="arg">${a}</span>`; });
return out;
}
function renderCommands(filter){
const root = document.getElementById("results-commands");
const meta = document.getElementById("resultmeta-commands");
root.innerHTML = "";
let total=0, shown=0;
const f=(filter||"").trim().toLowerCase();
COMMANDS.forEach(group=>{
const filtered = group.items.filter(it=>{
total++;
if(!f) return true;
return (it.cmd+" "+it.args.join(" ")+" "+it.desc+" "+it.ex).toLowerCase().includes(f);
});
if(filtered.length===0) return;
shown += filtered.length;
const catEl = document.createElement("div");
catEl.className="category";
catEl.id="cat-"+group.cat.replace(/\W+/g,"-");
catEl.innerHTML = `<h2>${group.cat}</h2>`;
const list = document.createElement("div");
list.className="cmdlist";
filtered.forEach(it=>{
const el = document.createElement("div");
el.className="cmd";
el.innerHTML = `
<div class="cmd-row">
<span class="cmd-syntax">${syntaxHTML(it)}</span>
<span class="pill ${levelClass(it.level)}">${it.level}</span>
<button class="copybtn" type="button">copy</button>
</div>
<div class="cmd-desc">${it.desc}</div>
<div class="cmd-example">${it.ex}</div>`;
el.querySelector(".copybtn").addEventListener("click",(e)=>{
copyText(it.ex); e.target.textContent="copied"; setTimeout(()=>{e.target.textContent="copy";},1200);
});
list.appendChild(el);
});
catEl.appendChild(list);
root.appendChild(catEl);
});
if(shown===0) root.innerHTML = `<div class="empty">No command matches "${filter}".</div>`;
meta.textContent = f ? `${shown} of ${total} commands match` : `${total} commands across ${COMMANDS.length} categories`;
}
function buildCommandsNav(){
const nav = document.getElementById("nav-commands");
COMMANDS.forEach(group=>{
const link = document.createElement("div");
link.className="navlink";
link.innerHTML = `<span>${group.cat}</span><span class="count">${group.items.length}</span>`;
link.addEventListener("click", ()=>{
document.getElementById("q-commands").value="";
renderCommands("");
const t = document.getElementById("cat-"+group.cat.replace(/\W+/g,"-"));
if(t) t.scrollIntoView({behavior:"smooth", block:"start"});
});
nav.appendChild(link);
});
}
document.getElementById("q-commands").addEventListener("input", e=>renderCommands(e.target.value));
/* ---------------- ITEMS TAB ---------------- */
let ITEMS = {};
let selectedItem = null;
function fmtCmd(itemtype, itemnum){
const amount = document.getElementById("cfg-amount").value || 1;
const refine = document.getElementById("cfg-refine").value || 0;
const life = document.getElementById("cfg-life").value || 100;
const socket = document.getElementById("cfg-socket").value || 0;
const stats = document.getElementById("cfg-stats").value || 0;
const target = document.getElementById("give-to").value.trim();
if(target){
return `/give2 ${target} ${itemnum} ${itemtype} ${amount} ${refine} ${life} ${socket} ${stats}`;
}
return `/item ${itemnum} ${itemtype} ${amount} ${refine} ${life} ${socket} ${stats}`;
}
function openConfigurator(itemtype, itemnum, name){
selectedItem = {itemtype, itemnum, name};
document.getElementById("configurator").style.display = "block";
document.getElementById("cfg-title").textContent = `${name} · itemtype ${itemtype} / itemnum ${itemnum}`;
updateConfiguratorOutput();
document.querySelectorAll(".item-card").forEach(c=>c.classList.remove("sel"));
const el = document.querySelector(`.item-card[data-itemtype="${itemtype}"][data-itemnum="${itemnum}"]`);
if(el) el.classList.add("sel");
}
function updateConfiguratorOutput(){
if(!selectedItem) return;
document.getElementById("cfg-command").textContent = fmtCmd(selectedItem.itemtype, selectedItem.itemnum);
}
["cfg-amount","cfg-refine","cfg-life","cfg-socket","cfg-stats"].forEach(id=>{
document.getElementById(id).addEventListener("input", updateConfiguratorOutput);
});
document.getElementById("give-to").addEventListener("input", updateConfiguratorOutput);
document.getElementById("cfg-copy").addEventListener("click", (e)=>{
copyText(document.getElementById("cfg-command").textContent);
e.target.textContent = "copied!"; setTimeout(()=>{e.target.textContent="Copy command";},1200);
});
function renderItems(){
const root = document.getElementById("results-items");
const meta = document.getElementById("resultmeta-items");
const cat = document.getElementById("item-cat").value;
const q = document.getElementById("q-items").value.trim().toLowerCase();
root.innerHTML = "";
if(!q && cat === "__all__"){
meta.textContent = "Pick a category or search by name/id to browse — 9,951 items total.";
return;
}
let pool = [];
const cats = cat === "__all__" ? Object.keys(ITEMS) : [cat];
cats.forEach(k=>{
ITEMS[k].items.forEach(([id,name])=>pool.push({itemtype:+k, id, name, catname:ITEMS[k].name}));
});
let filtered = pool;
if(q){
filtered = pool.filter(it => it.name.toLowerCase().includes(q) || String(it.id)===q);
}
const CAP = 180;
const total = filtered.length;
const shown = filtered.slice(0, CAP);
shown.forEach(it=>{
const el = document.createElement("div");
el.className = "item-card";
el.dataset.itemtype = it.itemtype;
el.dataset.itemnum = it.id;
el.innerHTML = `<div class="nm">${it.name}</div><div class="id">type ${it.itemtype} · id ${it.id}</div>`;
el.addEventListener("click", ()=>openConfigurator(it.itemtype, it.id, it.name));
root.appendChild(el);
});
if(total===0){
root.innerHTML = `<div class="empty">No item matches.</div>`;
}
meta.textContent = total > CAP
? `Showing first ${CAP} of ${total} matches — narrow your search to see more.`
: `${total} item${total===1?"":"s"}`;
}
document.getElementById("q-items").addEventListener("input", renderItems);
document.getElementById("item-cat").addEventListener("change", renderItems);
/* ---------------- MAPS TAB ---------------- */
let MAPS = [];
function renderMaps(){
const root = document.getElementById("results-maps");
const sort = document.getElementById("map-sort").value;
const sorted = [...MAPS].sort((a,b)=>{
if(sort==="monsters") return b.totalMonsters - a.totalMonsters;
if(sort==="npcs") return b.npcCount - a.npcCount;
return a.id - b.id;
});
root.innerHTML = "";
sorted.forEach(m=>{
const el = document.createElement("div");
el.className = "map-card";
const bounds = m.bounds
? `<div>X range</div><b>${m.bounds.minX} – ${m.bounds.maxX}</b><div>Y range</div><b>${m.bounds.minY} – ${m.bounds.maxY}</b>`
: `<div>Coordinates</div><b>no spawn data</b><div></div><b></b>`;
el.innerHTML = `
<h3>Map ${m.id}</h3>
${m.note ? `<div class="note">${m.note}</div>` : ""}
<div class="map-stats">
<div>Spawn points</div><b>${m.spawnPoints}</b>
<div>Monsters (max pop.)</div><b>${m.totalMonsters}</b>
<div>NPCs</div><b>${m.npcCount}</b>
${bounds}
</div>`;
root.appendChild(el);
});
}
document.getElementById("map-sort").addEventListener("change", renderMaps);
/* ---------------- boot ---------------- */
buildCommandsNav();
renderCommands("");
fetch("items.json").then(r=>r.json()).then(data=>{
ITEMS = data;
const sel = document.getElementById("item-cat");
sel.innerHTML = `<option value="__all__">All categories</option>` +
Object.keys(data).sort((a,b)=>+a-+b).map(k=>`<option value="${k}">${data[k].name} (${data[k].items.length})</option>`).join("");
renderItems();
});
fetch("maps.json").then(r=>r.json()).then(data=>{
MAPS = data;
renderMaps();
});
</script>
</body>
</html>