Fix: decode base64-corrupted admin html/css/js

This commit is contained in:
jessikitty committed 2026-06-19 01:21:26 +00:00
1 parent 3fa50c4c2f
commit 327b37babb
3 files changed
+575 -3

No files matched your search

+148 -1
View File
@@ -1 +1,148 @@
PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CiAgPG1ldGEgY2hhcnNldD0idXRmLTgiIC8+CiAgPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xIiAvPgogIDx0aXRsZT5OZXdidXJ5IE5pZ2h0cyDigJQgQWRtaW48L3RpdGxlPgogIDxsaW5rIHJlbD0ic3R5bGVzaGVldCIgaHJlZj0iL2Nzcy9hcHAuY3NzIiAvPgogIDxsaW5rIHJlbD0ic3R5bGVzaGVldCIgaHJlZj0iL2Nzcy9hZG1pbi5jc3MiIC8+CjwvaGVhZD4KPGJvZHk+CiAgPCEtLSBMb2dpbiAtLT4KICA8ZGl2IGlkPSJsb2dpbiIgY2xhc3M9ImxvZ2luLXdyYXAiPgogICAgPGRpdiBjbGFzcz0ibG9naW4tY2FyZCI+CiAgICAgIDxoMSBjbGFzcz0ibG9naW4tdGl0bGUiPk5ld2J1cnk8c3Bhbj5OaWdodHM8L3NwYW4+PC9oMT4KICAgICAgPGRpdiBjbGFzcz0ibG9naW4tc3ViIG1vbm8iPkFETUlOIENPTlNPTEU8L2Rpdj4KICAgICAgPGxhYmVsIGZvcj0ibGctdXNlciI+VXNlcm5hbWU8L2xhYmVsPgogICAgICA8aW5wdXQgaWQ9ImxnLXVzZXIiIGF1dG9jb21wbGV0ZT0idXNlcm5hbWUiIC8+CiAgICAgIDxsYWJlbCBmb3I9ImxnLXBhc3MiPlBhc3N3b3JkPC9sYWJlbD4KICAgICAgPGlucHV0IGlkPSJsZy1wYXNzIiB0eXBlPSJwYXNzd29yZCIgYXV0b2NvbXBsZXRlPSJjdXJyZW50LXBhc3N3b3JkIiAvPgogICAgICA8YnV0dG9uIGNsYXNzPSJwcmltYXJ5IiBpZD0ibGctZ28iIHN0eWxlPSJ3aWR0aDoxMDAlO21hcmdpbi10b3A6MTRweCI+U2lnbiBpbjwvYnV0dG9uPgogICAgICA8ZGl2IGlkPSJsZy1lcnJvciIgY2xhc3M9ImxnLWVycm9yIGhpZGRlbiI+PC9kaXY+CiAgICA8L2Rpdj4KICA8L2Rpdj4KCiAgPCEtLSBBcHAgLS0+CiAgPGRpdiBpZD0iYXBwIiBjbGFzcz0iaGlkZGVuIj4KICAgIDxoZWFkZXIgY2xhc3M9InRvcGJhciI+CiAgICAgIDxkaXYgY2xhc3M9ImJyYW5kIj5OZXdidXJ5PHNwYW4+TmlnaHRzPC9zcGFuPiA8c3BhbiBjbGFzcz0iYmFkZ2UgbW9ubyI+QURNSU48L3NwYW4+PC9kaXY+CiAgICAgIDxuYXYgY2xhc3M9InRhYnMiPgogICAgICAgIDxidXR0b24gY2xhc3M9InRhYiBhY3RpdmUiIGRhdGEtdGFiPSJnaG9zdHMiPkdob3N0czwvYnV0dG9uPgogICAgICAgIDxidXR0b24gY2xhc3M9InRhYiIgZGF0YS10YWI9InNldHMiPlNldHM8L2J1dHRvbj4KICAgICAgICA8YnV0dG9uIGNsYXNzPSJ0YWIiIGRhdGEtdGFiPSJhY2NvdW50Ij5BY2NvdW50PC9idXR0b24+CiAgICAgIDwvbmF2PgogICAgICA8YnV0dG9uIGlkPSJsb2dvdXQiIGNsYXNzPSJzbWFsbCI+U2lnbiBvdXQ8L2J1dHRvbj4KICAgIDwvaGVhZGVyPgoKICAgIDwhLS0gR0hPU1RTIC0tPgogICAgPHNlY3Rpb24gaWQ9InRhYi1naG9zdHMiIGNsYXNzPSJ0YWItcGFuZWwiPgogICAgICA8ZGl2IGNsYXNzPSJwYW5lbC1oZWFkIj4KICAgICAgICA8aDI+R2hvc3RzPC9oMj4KICAgICAgICA8ZGl2IGNsYXNzPSJoZWFkLWFjdGlvbnMiPgogICAgICAgICAgPGlucHV0IGlkPSJnaG9zdC1zZWFyY2giIHBsYWNlaG9sZGVyPSJTZWFyY2ggbmFtZSAvIGFiaWxpdHnigKYiIHN0eWxlPSJ3aWR0aDoyMjBweCIgLz4KICAgICAgICAgIDxidXR0b24gY2xhc3M9InByaW1hcnkiIGlkPSJuZXctZ2hvc3QiPisgTmV3IGdob3N0PC9idXR0b24+CiAgICAgICAgPC9kaXY+CiAgICAgIDwvZGl2PgogICAgICA8ZGl2IGNsYXNzPSJ0YWJsZS13cmFwIj4KICAgICAgICA8dGFibGUgaWQ9Imdob3N0cy10YWJsZSI+CiAgICAgICAgICA8dGhlYWQ+CiAgICAgICAgICAgIDx0cj48dGg+PC90aD48dGg+TmFtZSAvIERpc3BsYXk8L3RoPjx0aD5UeXBlPC90aD48dGg+UmFyaXR5PC90aD48dGg+SFA8L3RoPjx0aD5ETUc8L3RoPjx0aD5BYmlsaXR5PC90aD48dGg+U2V0PC90aD48dGg+T248L3RoPjx0aD48L3RoPjwvdHI+CiAgICAgICAgICA8L3RoZWFkPgogICAgICAgICAgPHRib2R5PjwvdGJvZHk+CiAgICAgICAgPC90YWJsZT4KICAgICAgPC9kaXY+CiAgICA8L3NlY3Rpb24+CgogICAgPCEtLSBTRVRTIC0tPgogICAgPHNlY3Rpb24gaWQ9InRhYi1zZXRzIiBjbGFzcz0idGFiLXBhbmVsIGhpZGRlbiI+CiAgICAgIDxkaXYgY2xhc3M9InBhbmVsLWhlYWQiPgogICAgICAgIDxoMj5TZXRzPC9oMj4KICAgICAgICA8YnV0dG9uIGNsYXNzPSJwcmltYXJ5IiBpZD0ibmV3LXNldCI+KyBOZXcgc2V0PC9idXR0b24+CiAgICAgIDwvZGl2PgogICAgICA8ZGl2IGlkPSJzZXRzLWxpc3QiIGNsYXNzPSJzZXRzLWxpc3QiPjwvZGl2PgogICAgPC9zZWN0aW9uPgoKICAgIDwhLS0gQUNDT1VOVCAtLT4KICAgIDxzZWN0aW9uIGlkPSJ0YWItYWNjb3VudCIgY2xhc3M9InRhYi1wYW5lbCBoaWRkZW4iPgogICAgICA8ZGl2IGNsYXNzPSJwYW5lbC1oZWFkIj48aDI+QWNjb3VudDwvaDI+PC9kaXY+CiAgICAgIDxkaXYgY2xhc3M9ImFjY291bnQtY2FyZCI+CiAgICAgICAgPHAgY2xhc3M9Im11dGVkIj5TaWduZWQgaW4gYXMgPHN0cm9uZyBpZD0iYWNjdC11c2VyIj7igJQ8L3N0cm9uZz4uIENoYW5nZSB5b3VyIHBhc3N3b3JkIGJlbG93LjwvcD4KICAgICAgICA8bGFiZWw+Q3VycmVudCBwYXNzd29yZDwvbGFiZWw+PGlucHV0IGlkPSJjcC1jdXJyZW50IiB0eXBlPSJwYXNzd29yZCIgLz4KICAgICAgICA8bGFiZWw+TmV3IHBhc3N3b3JkIChtaW4gOCBjaGFycyk8L2xhYmVsPjxpbnB1dCBpZD0iY3AtbmV3IiB0eXBlPSJwYXNzd29yZCIgLz4KICAgICAgICA8YnV0dG9uIGNsYXNzPSJwcmltYXJ5IiBpZD0iY3AtZ28iIHN0eWxlPSJtYXJnaW4tdG9wOjEycHgiPlVwZGF0ZSBwYXNzd29yZDwvYnV0dG9uPgogICAgICAgIDxkaXYgaWQ9ImNwLW1zZyIgY2xhc3M9ImNwLW1zZyBoaWRkZW4iPjwvZGl2PgogICAgICA8L2Rpdj4KICAgIDwvc2VjdGlvbj4KICA8L2Rpdj4KCiAgPCEtLSBHaG9zdCBlZGl0b3IgbW9kYWwgLS0+CiAgPGRpdiBpZD0iZ2hvc3QtbW9kYWwiIGNsYXNzPSJtb2RhbCBoaWRkZW4iPgogICAgPGRpdiBjbGFzcz0ibW9kYWwtY2FyZCI+CiAgICAgIDxoMyBpZD0iZ20tdGl0bGUiPkVkaXQgZ2hvc3Q8L2gzPgogICAgICA8ZGl2IGNsYXNzPSJncmlkMiI+CiAgICAgICAgPGRpdj48bGFiZWw+TmFtZTwvbGFiZWw+PGlucHV0IGlkPSJnbS1uYW1lIiAvPjwvZGl2PgogICAgICAgIDxkaXY+PGxhYmVsPkRpc3BsYXkgbmFtZSAobG9jay1vbiBsYWJlbCk8L2xhYmVsPjxpbnB1dCBpZD0iZ20tZGlzcGxheSIgLz48L2Rpdj4KICAgICAgICA8ZGl2PjxsYWJlbD5UeXBlPC9sYWJlbD4KICAgICAgICAgIDxzZWxlY3QgaWQ9ImdtLXR5cGUiPjxvcHRpb24gdmFsdWU9InJlZCI+UmVkPC9vcHRpb24+PG9wdGlvbiB2YWx1ZT0ieWVsbG93Ij5ZZWxsb3c8L29wdGlvbj48b3B0aW9uIHZhbHVlPSJibHVlIj5CbHVlPC9vcHRpb24+PC9zZWxlY3Q+CiAgICAgICAgPC9kaXY+CiAgICAgICAgPGRpdj48bGFiZWw+UmFyaXR5ICgx4oCTNCk8L2xhYmVsPjxpbnB1dCBpZD0iZ20tcmFyaXR5IiB0eXBlPSJudW1iZXIiIG1pbj0iMSIgbWF4PSI0IiAvPjwvZGl2PgogICAgICAgIDxkaXY+PGxhYmVsPkhlYWx0aDwvbGFiZWw+PGlucHV0IGlkPSJnbS1oZWFsdGgiIHR5cGU9Im51bWJlciIgLz48L2Rpdj4KICAgICAgICA8ZGl2PjxsYWJlbD5EYW1hZ2U8L2xhYmVsPjxpbnB1dCBpZD0iZ20tZGFtYWdlIiB0eXBlPSJudW1iZXIiIC8+PC9kaXY+CiAgICAgICAgPGRpdj48bGFiZWw+U3BlZWQgKDDigJM1KTwvbGFiZWw+PGlucHV0IGlLine truncated <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Newbury Nights — Admin</title>
<link rel="stylesheet" href="/css/app.css" />
<link rel="stylesheet" href="/css/admin.css" />
</head>
<body>
<!-- Login -->
<div id="login" class="login-wrap">
<div class="login-card">
<h1 class="login-title">Newbury<span>Nights</span></h1>
<div class="login-sub mono">ADMIN CONSOLE</div>
<label for="lg-user">Username</label>
<input id="lg-user" autocomplete="username" />
<label for="lg-pass">Password</label>
<input id="lg-pass" type="password" autocomplete="current-password" />
<button class="primary" id="lg-go" style="width:100%;margin-top:14px">Sign in</button>
<div id="lg-error" class="lg-error hidden"></div>
</div>
</div>
<!-- App -->
<div id="app" class="hidden">
<header class="topbar">
<div class="brand">Newbury<span>Nights</span> <span class="badge mono">ADMIN</span></div>
<nav class="tabs">
<button class="tab active" data-tab="ghosts">Ghosts</button>
<button class="tab" data-tab="sets">Sets</button>
<button class="tab" data-tab="account">Account</button>
</nav>
<button id="logout" class="small">Sign out</button>
</header>
<!-- GHOSTS -->
<section id="tab-ghosts" class="tab-panel">
<div class="panel-head">
<h2>Ghosts</h2>
<div class="head-actions">
<input id="ghost-search" placeholder="Search name / ability…" style="width:220px" />
<button class="primary" id="new-ghost">+ New ghost</button>
</div>
</div>
<div class="table-wrap">
<table id="ghosts-table">
<thead>
<tr><th></th><th>Name / Display</th><th>Type</th><th>Rarity</th><th>HP</th><th>DMG</th><th>Ability</th><th>Set</th><th>On</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- SETS -->
<section id="tab-sets" class="tab-panel hidden">
<div class="panel-head">
<h2>Sets</h2>
<button class="primary" id="new-set">+ New set</button>
</div>
<div id="sets-list" class="sets-list"></div>
</section>
<!-- ACCOUNT -->
<section id="tab-account" class="tab-panel hidden">
<div class="panel-head"><h2>Account</h2></div>
<div class="account-card">
<p class="muted">Signed in as <strong id="acct-user">—</strong>. Change your password below.</p>
<label>Current password</label><input id="cp-current" type="password" />
<label>New password (min 8 chars)</label><input id="cp-new" type="password" />
<button class="primary" id="cp-go" style="margin-top:12px">Update password</button>
<div id="cp-msg" class="cp-msg hidden"></div>
</div>
</section>
</div>
<!-- Ghost editor modal -->
<div id="ghost-modal" class="modal hidden">
<div class="modal-card">
<h3 id="gm-title">Edit ghost</h3>
<div class="grid2">
<div><label>Name</label><input id="gm-name" /></div>
<div><label>Display name (lock-on label)</label><input id="gm-display" /></div>
<div><label>Type</label>
<select id="gm-type"><option value="red">Red</option><option value="yellow">Yellow</option><option value="blue">Blue</option></select>
</div>
<div><label>Rarity (1–4)</label><input id="gm-rarity" type="number" min="1" max="4" /></div>
<div><label>Health</label><input id="gm-health" type="number" /></div>
<div><label>Damage</label><input id="gm-damage" type="number" /></div>
<div><label>Speed (0–5)</label><input id="gm-speed" type="number" min="0" max="5" /></div>
<div><label>Range (0–5)</label><input id="gm-range" type="number" min="0" max="5" /></div>
<div><label>Charge shot (0–3)</label><input id="gm-charge" type="number" min="0" max="3" /></div>
<div><label>Ability</label><input id="gm-ability" /></div>
<div><label>Set number (ref)</label><input id="gm-setnum" /></div>
<div><label>Set name (ref)</label><input id="gm-setname" /></div>
</div>
<div class="checks">
<label class="chk"><input type="checkbox" id="gm-boss" /> Boss ghost</label>
<label class="chk"><input type="checkbox" id="gm-enabled" checked /> Enabled</label>
</div>
<div class="image-row">
<label>Billboard (GIF / PNG / WebP, or MP4 / WebM video)</label>
<div class="row">
<input id="gm-file" type="file" accept=".gif,.png,.jpg,.jpeg,.webp,.webm,.mp4" />
<img id="gm-preview" class="gm-preview hidden" alt="" />
<video id="gm-preview-vid" class="gm-preview hidden" muted loop playsinline></video>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">Upload happens after you save the ghost. MP4 files are converted to a transparent WebM (with a WebP fallback) on the server.</div>
</div>
<div class="modal-actions">
<button data-close>Cancel</button>
<button class="primary" id="gm-save">Save</button>
</div>
</div>
</div>
<!-- Set editor modal -->
<div id="set-modal" class="modal hidden">
<div class="modal-card">
<h3 id="sm-title">Edit set</h3>
<div class="grid2">
<div><label>Scan code (QR payload)</label><input id="sm-code" placeholder="NN-70419" /></div>
<div><label>Set number (ref)</label><input id="sm-setnum" /></div>
<div><label>Set name</label><input id="sm-setname" /></div>
<div><label>Boss ghost</label><select id="sm-boss"><option value="">— none —</option></select></div>
</div>
<label class="chk"><input type="checkbox" id="sm-enabled" checked /> Enabled</label>
<div class="roster-pick">
<label>Roster (ghosts returned on scan)</label>
<input id="sm-roster-search" placeholder="Filter ghosts…" />
<div id="sm-roster" class="roster-checklist"></div>
</div>
<div class="modal-actions">
<button data-close>Cancel</button>
<button class="primary" id="sm-save">Save set</button>
</div>
</div>
</div>
<div class="disclaimer">
Fan-made tribute. Not affiliated with, sponsored by, or endorsed by the LEGO Group.
LEGO® and Hidden Side™ are trademarks of the LEGO Group.
</div>
<script type="module" src="/js/admin.js"></script>
</body>
</html>
+70 -1
View File
@@ -1 +1,70 @@
LmxvZ2luLXdyYXAgeyBtaW4taGVpZ2h0OiAxMDBkdmg7IGRpc3BsYXk6IGdyaWQ7IHBsYWNlLWl0ZW1zOiBjZW50ZXI7IHBhZGRpbmc6IDI0cHg7IH0KLmxvZ2luLWNhcmQgeyBiYWNrZ3JvdW5kOiB2YXIoLS1wYW5lbCk7IGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLWxpbmUpOyBib3JkZXItcmFkaXVzOiB2YXIoLS1yYWQpOyBwYWRkaW5nOiAzMHB4OyB3aWR0aDogMTAwJTsgbWF4LXdpZHRoOiAzNjBweDsgYm94LXNoYWRvdzogdmFyKC0tc2hhZG93KTsgfQoubG9naW4tdGl0bGUgeyBmb250LXNpemU6IDM4cHg7IGxpbmUtaGVpZ2h0OiAuOTsgbWFyZ2luOiAwOyB9Ci5sb2dpbi10aXRsZSBzcGFuIHsgY29sb3I6IHZhcigtLWJsdWUpOyB9Ci5sb2dpbi1zdWIgeyBmb250LXNpemU6IDExcHg7IGxldHRlci1zcGFjaW5nOiAuM2VtOyBjb2xvcjogdmFyKC0tdGV4dC1kaW0pOyBtYXJnaW46IDZweCAwIDIycHg7IH0KLmxvZ2luLWNhcmQgbGFiZWwgeyBtYXJnaW4tdG9wOiAxMnB4OyB9Ci5sZy1lcnJvciwgLmNwLW1zZyB7IGNvbG9yOiB2YXIoLS1kYW5nZXIpOyBmb250LXNpemU6IDEzcHg7IG1hcmdpbi10b3A6IDEycHg7IH0KLmNwLW1zZy5vayB7IGNvbG9yOiB2YXIoLS1nbG9vbSk7IH0KCi50b3BiYXIgeyBkaXNwbGF5OiBmbGV4OyBhbGlnbi1pdGVtczogY2VudGVyOyBnYXA6IDE4cHg7IHBhZGRpbmc6IDE0cHggMjBweDsgYm9yZGVyLWJvdHRvbTogMXB4IHNvbGlkIHZhcigtLWxpbmUpOyBwb3NpdGlvbjogc3RpY2t5OyB0b3A6IDA7IGJhY2tncm91bmQ6IHJnYmEoNyw5LDE1LC44NSk7IGJhY2tkcm9wLWZpbHRlcjogYmx1cigxMHB4KTsgei1pbmRleDogMjA7IH0KLmJyYW5kIHsgZm9udC1mYW1pbHk6IHZhcigtLWRpc3BsYXkpOyBmb250LXNpemU6IDIwcHg7IHRleHQtdHJhbnNmb3JtOiB1cHBlcmNhc2U7IGxldHRlci1zcGFjaW5nOiAuMDRlbTsgfQouYnJhbmQgc3BhbiB7IGNvbG9yOiB2YXIoLS1ibHVlKTsgfQouYmFkZ2UgeyBmb250LXNpemU6IDEwcHg7IGxldHRlci1zcGFjaW5nOiAuMmVtOyBiYWNrZ3JvdW5kOiB2YXIoLS1wYW5lbC0yKTsgYm9yZGVyOiAxcHggc29saWQgdmFyKC0tbGluZSk7IHBhZGRpbmc6IDJweCA3cHg7IGJvcmRlci1yYWRpdXM6IDVweDsgY29sb3I6IHZhcigtLXRleHQtZGltKTsgfQoudGFicyB7IGRpc3BsYXk6IGZsZXg7IGdhcDogNnB4OyBtYXJnaW4tbGVmdDogYXV0bzsgfQoudGFiIHsgYmFja2dyb3VuZDogdHJhbnNwYXJlbnQ7IGJvcmRlci1jb2xvcjogdHJhbnNwYXJlbnQ7IGNvbG9yOiB2YXIoLS10ZXh0LWRpbSk7IHBhZGRpbmc6IDhweCAxNHB4OyB9Ci50YWIuYWN0aXZlIHsgY29sb3I6IHZhcigtLXRleHQpOyBib3JkZXItY29sb3I6IHZhcigtLWxpbmUpOyBiYWNrZ3JvdW5kOiB2YXIoLS1wYW5lbCk7IH0KI2xvZ291dCB7IHBhZGRpbmc6IDhweCAxMnB4OyB9CgoudGFiLXBhbmVsIHsgcGFkZGluZzogMjJweCAyMHB4IDYwcHg7IG1heC13aWR0aDogMTEwMHB4OyBtYXJnaW46IDAgYXV0bzsgfQoucGFuZWwtaGVhZCB7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGp1c3RpZnktY29udGVudDogc3BhY2UtYmV0d2VlbjsgZ2FwOiAxNHB4OyBtYXJnaW4tYm90dG9tOiAxNnB4OyBmbGV4LXdyYXA6IHdyYXA7IH0KLnBhbmVsLWhlYWQgaDIgeyBmb250LXNpemU6IDI0cHg7IG1hcmdpbjogMDsgfQouaGVhZC1hY3Rpb25zIHsgZGlzcGxheTogZmxleDsgZ2FwOiAxMHB4OyBhbGlnbi1pdGVtczogY2VudGVyOyB9CgoudGFibGUtd3JhcCB7IG92ZXJmbG93LXg6IGF1dG87IGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLWxpbmUpOyBib3JkZXItcmFkaXVzOiB2YXIoLS1yYWQpOyB9CnRhYmxlIHsgd2lkdGg6IDEwMCU7IGJvcmRlci1jb2xsYXBzZTogY29sbGFwc2U7IGZvbnQtc2l6ZTogMTRweDsgbWluLXdpZHRoOiA3NjBweDsgfQp0aCwgdGQgeyB0ZXh0LWFsaWduOiBsZWZ0OyBwYWRkaW5nOiAxMHB4IDEycHg7IGJvcmRlci1ib3R0b206IDFweCBzb2xpZCB2YXIoLS1saW5lKTsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgfQp0aCB7IGZvbnQtZmFtaWx5OiB2YXIoLS1tb25vKTsgZm9udC1zaXplOiAxMXB4OyBsZXR0ZXItc3BhY2luZzogLjEyZW07IHRleHQtdHJhbnNmb3JtOiB1cHBlcmNhc2U7IGNvbG9yOiB2YXIoLS10ZXh0LWRpbSk7IHBvc2l0aW9uOiBzdGlja3k7IHRvcDogMDsgYmFja2dyb3VuZDogdmFyKC0tYmctc29mdCk7IH0KdGJvZHkgdHI6aG92ZXIgeyBiYWNrZ3JvdW5kOiB2YXIoLS1wYW5lbCk7IH0KdGQgLnN1YiB7IGNvbG9yOiB2YXIoLS10ZXh0LWRpbSk7IGZvbnQtc2l6ZTogMTJweDsgfQoucGlsbCB7IGZvbnQtc2l6ZTogMTFweDsgcGFkZGluZzogMnB4IDhweDsgYm9yZGVyLXJhZGl1czogOTk5cHg7IGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLWxpbmUpOyBmb250LWZhbWlseTogdmFyKC0tbW9ubyk7IH0KLnRvZ2dsZSB7IGN1cnNvcjogcG9pbnRlcjsgZm9udC1zaXplOiAxOHB4OyB9Ci50b2dnbGUub24geyBjb2xvcjogdmFyKC0tZ2xvb20pOyB9IC50b2dnbGUub2ZmIHsgY29sb3I6IHZhcigtLXRleHQtZGltKTsgfQoucm93LWFjdGlvbnMgeyBkaXNwbGF5OiBmbGV4OyBnYXA6IDZweDsgfQoucm93LWFjdGlvbnMgYnV0dG9uIHsgcGFkZGluZzogNXB4IDlweDsgZm9udC1zaXplOiAxMXB4OyB9Ci50aHVtYi1jZWxsIGltZywgLnRodW1iLWNlbGwgdmlkZW8geyB3aWR0aDogMzRweDsgaGVpZ2h0OiAzNHB4OyBvYmplY3QtZml0OiBjb250YWluOyBiYWNrZ3JvdW5kOiAjMDAwYTsgYm9yZGVyLXJhZGl1czogNnB4OyB9Cgouc2V0cy1saXN0IHsgZGlzcGxheTogZ3JpZDsgZ2FwOiAxNHB4OyB9Ci5zZXQtY2FyZCB7IGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLWxpbmUpOyBib3JkZXItcmFkaXVzOiB2YXIoLS1yYWQpOyBwYWRkaW5nOiAxNnB4OyBiYWNrZ3JvdW5kOiB2YXIoLS1wYW5lbCk7IH0KLnNldC1jYXJkLm9mZiB7IG9wYWNpdHk6IC41NTsgfQouc2V0LWhlYWQgeyBkaXNwbGF5OiBmbGV4OyBhbGlnbi1pdGVtczogY2VudGVyOyBnYXA6IDEycHg7IGZsZXgtd3JhcDogd3JhcDsgfQouc2V0LWhlYWQgLmNvZGUgeyBmb250LWZhbWlseTogdmFyKC0tbW9ubyk7IGJhY2tncm91bmQ6IHZhcigtLWJnLXNvZnQpOyBwYWRkaW5nOiAzcHggOHB4OyBib3JkZXItcmFkaXVzOiA2cHg7IGZvbnQtc2l6ZTogMTNweDsgfQouc2V0LWhlYWQgLnNuYW1lIHsgZm9udC1mYW1pbHk6IHZhcigtLWRpc3BsYXkpOyB0ZXh0LXRyYW5zZm9ybTogdXBwZXJjYXNlOyBsZXR0ZXItc3BhY2luZzogLjAzZW07IH0KLnNldC1oZWFkIC5ncm93IHsgZmxleDogMTsgfQouc2V0LXJvc3RlciB7IG1hcmdpbi10b3A6IDEwcHg7IGRpc3BsYXk6IGZsZXg7IGZsZXgtd3JhcDogd3JhcDsgZ2FwOiA2cHg7IH0KLnJjaGlwIHsgZm9udC1zaXplOiAxMXB4OyBwYWRkaW5nOiAzcHggOHB4OyBib3JkZXItcmFkaXVzOiA2cHg7IGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLWxpbmUpOyBkaXNwbGF5OiBmbGV4OyBhbGlnbi1pdGVtczogY2VudGVyOyBnYXA6IDVweDsgfQoKLmFjY291bnQtY2FyZCB7IG1heC13aWR0aDogNDIwcHg7IGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLWxpbmUpOyBib3JkZXItcmFkaXVzOiB2YXIoLS1yYWQpOyBwYWRkaW5nOiAyMHB4OyBiYWNrZ3JvdW5kOiB2YXIoLS1wYW5lbCk7IH0KLmFjY291bnQtY2FyZCBsYWJlbCB7IG1hcmdpbi10b3A6IDEycHg7IH0KCi5tb2RhbCB7IHBvc2l0aW9uOiBmaXhlZDsgaW5zZXQ6IDA7IGJhY2tncm91bmQLine truncated .login-wrap { min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
.login-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--rad); padding: 30px; width: 100%; max-width: 360px; box-shadow: var(--shadow); }
.login-title { font-size: 38px; line-height: .9; margin: 0; }
.login-title span { color: var(--blue); }
.login-sub { font-size: 11px; letter-spacing: .3em; color: var(--text-dim); margin: 6px 0 22px; }
.login-card label { margin-top: 12px; }
.lg-error, .cp-msg { color: var(--danger); font-size: 13px; margin-top: 12px; }
.cp-msg.ok { color: var(--gloom); }
.topbar { display: flex; align-items: center; gap: 18px; padding: 14px 20px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: rgba(7,9,15,.85); backdrop-filter: blur(10px); z-index: 20; }
.brand { font-family: var(--display); font-size: 20px; text-transform: uppercase; letter-spacing: .04em; }
.brand span { color: var(--blue); }
.badge { font-size: 10px; letter-spacing: .2em; background: var(--panel-2); border: 1px solid var(--line); padding: 2px 7px; border-radius: 5px; color: var(--text-dim); }
.tabs { display: flex; gap: 6px; margin-left: auto; }
.tab { background: transparent; border-color: transparent; color: var(--text-dim); padding: 8px 14px; }
.tab.active { color: var(--text); border-color: var(--line); background: var(--panel); }
#logout { padding: 8px 12px; }
.tab-panel { padding: 22px 20px 60px; max-width: 1100px; margin: 0 auto; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; }
.panel-head h2 { font-size: 24px; margin: 0; }
.head-actions { display: flex; gap: 10px; align-items: center; }
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--rad); }
table { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 760px; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th { font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-dim); position: sticky; top: 0; background: var(--bg-soft); }
tbody tr:hover { background: var(--panel); }
td .sub { color: var(--text-dim); font-size: 12px; }
.pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); font-family: var(--mono); }
.toggle { cursor: pointer; font-size: 18px; }
.toggle.on { color: var(--gloom); } .toggle.off { color: var(--text-dim); }
.row-actions { display: flex; gap: 6px; }
.row-actions button { padding: 5px 9px; font-size: 11px; }
.thumb-cell img, .thumb-cell video { width: 34px; height: 34px; object-fit: contain; background: #000a; border-radius: 6px; }
.sets-list { display: grid; gap: 14px; }
.set-card { border: 1px solid var(--line); border-radius: var(--rad); padding: 16px; background: var(--panel); }
.set-card.off { opacity: .55; }
.set-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.set-head .code { font-family: var(--mono); background: var(--bg-soft); padding: 3px 8px; border-radius: 6px; font-size: 13px; }
.set-head .sname { font-family: var(--display); text-transform: uppercase; letter-spacing: .03em; }
.set-head .grow { flex: 1; }
.set-roster { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 6px; }
.rchip { font-size: 11px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--line); display: flex; align-items: center; gap: 5px; }
.account-card { max-width: 420px; border: 1px solid var(--line); border-radius: var(--rad); padding: 20px; background: var(--panel); }
.account-card label { margin-top: 12px; }
.modal { position: fixed; inset: 0; background: rgba(7,9,15,.8); backdrop-filter: blur(4px); display: grid; place-items: center; z-index: 50; padding: 18px; }
.modal-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--rad); padding: 22px; width: 100%; max-width: 620px; max-height: 90dvh; overflow: auto; box-shadow: var(--shadow); }
.modal-card h3 { margin: 0 0 16px; font-size: 22px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.checks { display: flex; gap: 18px; margin: 14px 0; }
.chk { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text); margin: 0; }
.chk input { width: auto; }
.image-row { margin: 6px 0 4px; }
.gm-preview { width: 48px; height: 48px; object-fit: contain; background: #000a; border-radius: 8px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.roster-pick { margin-top: 14px; }
.roster-checklist { max-height: 240px; overflow: auto; border: 1px solid var(--line); border-radius: 10px; padding: 8px; margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
.rcheck { display: flex; align-items: center; gap: 7px; font-size: 12px; padding: 4px 6px; border-radius: 6px; }
.rcheck:hover { background: var(--panel-2); }
.rcheck input { width: auto; }
@media (max-width: 640px) {
.grid2, .roster-checklist { grid-template-columns: 1fr; }
.tabs { order: 3; width: 100%; }
}
+357 -1
View File
@@ -1 +1,357 @@
LyogTmV3YnVyeSBOaWdodHMg4oCUIGFkbWluIGNvbnNvbGUgKi8KY29uc3QgJCA9IChzLCByID0gZG9jdW1lbnQpID0+IHIucXVlcnlTZWxlY3RvcihzKTsKY29uc3QgJCQgPSAocywgciA9IGRvY3VtZW50KSA9PiBbLi4uci5xdWVyeVNlbGVjdG9yQWxsKHMpXTsKCmxldCBUT0tFTiA9IG51bGw7CmxldCBHSE9TVFMgPSBbXTsKbGV0IFNFVFMgPSBbXTsKCmNvbnN0IGFwaSA9IChwYXRoLCBvcHRzID0ge30pID0+CiAgZmV0Y2gocGF0aCwgewogICAgLi4ub3B0cywKICAgIGhlYWRlcnM6IHsKICAgICAgLi4uKG9wdHMuYm9keSAmJiAhKG9wdHMuYm9keSBpbnN0YW5jZW9mIEZvcm1EYXRhKSA/IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9IDoge30pLAogICAgICAuLi4oVE9LRU4gPyB7IEF1dGhvcml6YXRpb246IGBCZWFyZXIgJHtUT0tFTn1gIH0gOiB7fSksCiAgICAgIC4uLihvcHRzLmhlYWRlcnMgfHwge30pLAogICAgfSwKICB9KTsKCi8qIC0tLS0tLS0tLS0gQXV0aCAtLS0tLS0tLS0tICovCiQoJyNsZy1nbycpLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgbG9naW4pOwokKCcjbGctcGFzcycpLmFkZEV2ZW50TGlzdGVuZXIoJ2tleWRvd24nLCAoZSkgPT4geyBpZiAoZS5rZXkgPT09ICdFbnRlcicpIGxvZ2luKCk7IH0pOwoKYXN5bmMgZnVuY3Rpb24gbG9naW4oKSB7CiAgY29uc3QgdXNlcm5hbWUgPSAkKCcjbGctdXNlcicpLnZhbHVlLnRyaW0oKTsKICBjb25zdCBwYXNzd29yZCA9ICQoJyNsZy1wYXNzJykudmFsdWU7CiAgY29uc3QgZXJyID0gJCgnI2xnLWVycm9yJyk7CiAgZXJyLmNsYXNzTGlzdC5hZGQoJ2hpZGRlbicpOwogIGNvbnN0IHJlcyA9IGF3YWl0IGFwaSgnL2F1dGgvbG9naW4nLCB7IG1ldGhvZDogJ1BPU1QnLCBib2R5OiBKU09OLnN0cmluZ2lmeSh7IHVzZXJuYW1lLCBwYXNzd29yZCB9KSB9KTsKICBpZiAoIXJlcy5vaykgewogICAgZXJyLnRleHRDb250ZW50ID0gJ0ludmFsaWQgdXNlcm5hbWUgb3IgcGFzc3dvcmQuJzsKICAgIGVyci5jbGFzc0xpc3QucmVtb3ZlKCdoaWRkZW4nKTsKICAgIHJldHVybjsKICB9CiAgY29uc3QgZGF0YSA9IGF3YWl0IHJlcy5qc29uKCk7CiAgVE9LRU4gPSBkYXRhLnRva2VuOwogICQoJyNhY2N0LXVzZXInKS50ZXh0Q29udGVudCA9IGRhdGEudXNlci51c2VybmFtZTsKICAkKCcjbG9naW4nKS5jbGFzc0xpc3QuYWRkKCdoaWRkZW4nKTsKICAkKCcjYXBwJykuY2xhc3NMaXN0LnJlbW92ZSgnaGlkZGVuJyk7CiAgYXdhaXQgUHJvbWlzZS5hbGwoW2xvYWRHaG9zdHMoKSwgbG9hZFNldHMoKV0pOwp9CgokKCcjbG9nb3V0JykuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCBhc3luYyAoKSA9PiB7CiAgYXdhaXQgYXBpKCcvYXV0aC9sb2dvdXQnLCB7IG1ldGhvZDogJ1BPU1QnIH0pOwogIGxvY2F0aW9uLnJlbG9hZCgpOwp9KTsKCi8qIC0tLS0tLS0tLS0gVGFicyAtLS0tLS0tLS0tICovCiQkKCcudGFiJykuZm9yRWFjaCgodCkgPT4KICB0LmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgKCkgPT4gewogICAgJCQoJy50YWInKS5mb3JFYWNoKCh4KSA9PiB4LmNsYXNzTGlzdC5yZW1vdmUoJ2FjdGl2ZScpKTsKICAgIHQuY2xhc3NMaXN0LmFkZCgnYWN0aXZlJyk7CiAgICAkJCgnLnRhYi1wYW5lbCcpLmZvckVhY2goKHApID0+IHAuY2xhc3NMaXN0LmFkZCgnaGlkZGVuJykpOwogICAgJChgI3RhYi0ke3QuZGF0YXNldC50YWJ9YCkuY2xhc3NMaXN0LnJlbW92ZSgnaGlkZGVuJyk7CiAgfSkKKTsKCi8qIC0tLS0tLS0tLS0gR2hvc3RzIC0tLS0tLS0tLS0gKi8KYXN5bmMgZnVuY3Rpb24gbG9hZEdob3N0cygpIHsKICBjb25zdCByZXMgPSBhd2FpdCBhcGkoJy9hcGkvYWRtaW4vZ2hvc3RzJyk7CiAgR0hPU1RTID0gKGF3YWl0IHJlcy5qc29uKCkpLmdob3N0czsKICByZW5kZXJHaG9zdHMoKTsKfQoKJCgnI2dob3N0LXNlYXJjaCcpLmFkZEV2ZW50TGlzdGVuZXIoJ2lucHV0JywgcmVuZGVyR2hvc3RzKTsKCmZ1bmN0aW9uIHJlbmRlckdob3N0cygpIHsKICBjb25zdCBxID0gJCgnI2dob3N0LXNlYXJjaCcpLnZhbHVlLnRvTG93ZXJDYXNlKCk7CiAgY29uc3Qgcm93cyA9IEdIT1NUUy5maWx0ZXIoCiAgICAoZykgPT4gIXEgfHwgZy5uYW1lLnRvTG93ZXJDYXNlKCkuaW5jbHVkZXMocSkgfHwgKGcuYWJpbGl0eSB8fCAnJykudG9Mb3dlckNhc2UoKS5pbmNsdWRlcyhxKQogICkubWFwKGdob3N0Um93KS5qb2luKCcnKTsKICAkKCcjZ2hvc3RzLXRhYmxlIHRib2R5JykuaW5uZXJIVE1MID0gcm93czsKCiAgJCQoJyNnaG9zdHMtdGFibGUgLmVkaXQnKS5mb3JFYWNoKChiKSA9PgogICAgYi5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsICgpID0+IG9wZW5HaG9zdCgrYi5kYXRhc2V0LmlkKSkpOwogICQkKCcjZ2hvc3RzLXRhYmxlIC5kZWwnKS5mb3JFYWNoKChiKSA9PgogICAgYi5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsICgpID0+IGRlbGV0ZUdob3N0KCtiLmRhdGFzZXQuaWQpKSk7CiAgJCQoJyNnaG9zdHMtdGFibGUgLnRvZ2dsZScpLmZvckVhY2goKGIpID0+CiAgICBiLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgKCkgPT4gdG9nZ2xlR2hvc3QoK2IuZGF0YXNldC5pZCkpKTsKfQoKZnVuY3Rpb24gZ2hvc3RSb3coZykgewogIGNvbnN0IGltZyA9IGcuaW1hZ2VfcGF0aAogICAgPyBgPGltZyBzcmM9Ii91cGxvYWRzLyR7Zy5pbWFnZV9wYXRofSIgYWx0PSIiPmAKICAgIDogKGcud2VibV9wYXRoCiAgICAgICAgPyBgPHZpZGVvIHNyYz0iL3VwbG9hZHMvJHtnLndlYm1fcGF0aH0iIG11dGVkIGxvb3AgYXV0b3BsYXkgcGxheXNpbmxpbmU+PC92aWRlbz5gCiAgICAgICAgOiAnJyk7CiAgY29uc3Qgc2V0UmVmID0gZy5zZXRfbnVtYmVyID8gYCR7Zy5zZXRfbnVtYmVyfWAgOiAn4oCUJzsKICByZXR1cm4gYDx0cj4KICAgIDx0ZCBjbGFzcz0idGh1bWItY2VsbCI+JHtpbWd9PC90ZD4KICAgIDx0ZD48ZGl2PiR7ZXNjKGcubmFtZSl9JHtnLmlzX2Jvc3MgPyAnIDxzcGFuIGNsYXNzPSJwaWxsIj5ib3NzPC9zcGFuPicgOiAnJ308L2Rpdj4KICAgICAgICAke2cuZGlzcGxheV9uYW1lICYmIGcuZGlzcGxheV9uYW1lICE9PSBnLm5hbWUgPyBgPGRpdiBjbGFzcz0ic3ViIj7ihrMgJHtlc2MoZy5kaXNwbGF5X25hbWUpfTwvZGl2PmAgOiAnJ308L3RkPgogICAgPHRkPjxzcGFuIGNsYXNzPSJkb3QgJHtnLnR5cGV9Ij48L3NwYW4+ICR7Zy50eXBlfTwvdGQ+CiAgICA8dGQgY2xhc3M9InN0YXJzIj4keyfimIUnLnJlcGVhdChnLnJhcml0eSl9PC90ZD4KICAgIDx0ZD4ke2cuaGVhbHRofTwvdGQ+PHRkPiR7Zy5kYW1hZ2V9PC90ZD4KICAgIDx0ZD4ke2VzYyhnLmFiaWxpdHkgfHwgJ+KAlCcpfTwvdGQ+CiAgICA8dGQgY2xhc3M9Im1vbm8iPiR7c2V0UmVmfTwvdGQ+CiAgICA8dGQ+PHNwYW4gY2xhc3M9InRvZ2dsZSAke2cuZW5hYmxlZCA/ICdvbicgOiAnb2ZmJ30iIGRhdGEtaWQ9IiR7Zy5pZH0iPiR7Zy5lbmFibGVkID8gJ+KXiScgOiAn4pevJ308L3NwYW4+PC90ZD4KICAgIDx0ZD48ZGl2IGNsYXNzPSJyb3ctYWN0aW9ucyI+CiAgICAgIDxidXR0b24gY2xhc3M9ImVkaXQiIGRhdGEtaWQ9IiR7Zy5pZH0iPkVkaXQ8L2J1dHRvbj4KICAgICAgPGJ1dHRvbiBjbGFzcz0iZGFuZ2VyIGRlbCIgZGF0YS1pZD0iJHtnLmlkfSI+RGVsPC9idXR0b24+CiAgICA8L2Rpdj48L3RkPgogIDwvdHI+YDsKfQoKYXN5bmMgZnVuY3Rpb24gdG9nZ2xlR2hvc3QoaWQpIHsKICBjb25zdCBnID0gR0hPU1RTLmZpbmQoKHgpID0+IHguaWQgPT09IGlkKTsLine truncated /* Newbury Nights — admin console */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
let TOKEN = null;
let GHOSTS = [];
let SETS = [];
const api = (path, opts = {}) =>
fetch(path, {
...opts,
headers: {
...(opts.body && !(opts.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
...(opts.headers || {}),
},
});
/* ---------- Auth ---------- */
$('#lg-go').addEventListener('click', login);
$('#lg-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
async function login() {
const username = $('#lg-user').value.trim();
const password = $('#lg-pass').value;
const err = $('#lg-error');
err.classList.add('hidden');
const res = await api('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
if (!res.ok) {
err.textContent = 'Invalid username or password.';
err.classList.remove('hidden');
return;
}
const data = await res.json();
TOKEN = data.token;
$('#acct-user').textContent = data.user.username;
$('#login').classList.add('hidden');
$('#app').classList.remove('hidden');
await Promise.all([loadGhosts(), loadSets()]);
}
$('#logout').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
location.reload();
});
/* ---------- Tabs ---------- */
$$('.tab').forEach((t) =>
t.addEventListener('click', () => {
$$('.tab').forEach((x) => x.classList.remove('active'));
t.classList.add('active');
$$('.tab-panel').forEach((p) => p.classList.add('hidden'));
$(`#tab-${t.dataset.tab}`).classList.remove('hidden');
})
);
/* ---------- Ghosts ---------- */
async function loadGhosts() {
const res = await api('/api/admin/ghosts');
GHOSTS = (await res.json()).ghosts;
renderGhosts();
}
$('#ghost-search').addEventListener('input', renderGhosts);
function renderGhosts() {
const q = $('#ghost-search').value.toLowerCase();
const rows = GHOSTS.filter(
(g) => !q || g.name.toLowerCase().includes(q) || (g.ability || '').toLowerCase().includes(q)
).map(ghostRow).join('');
$('#ghosts-table tbody').innerHTML = rows;
$$('#ghosts-table .edit').forEach((b) =>
b.addEventListener('click', () => openGhost(+b.dataset.id)));
$$('#ghosts-table .del').forEach((b) =>
b.addEventListener('click', () => deleteGhost(+b.dataset.id)));
$$('#ghosts-table .toggle').forEach((b) =>
b.addEventListener('click', () => toggleGhost(+b.dataset.id)));
}
function ghostRow(g) {
const img = g.image_path
? `<img src="/uploads/${g.image_path}" alt="">`
: (g.webm_path
? `<video src="/uploads/${g.webm_path}" muted loop autoplay playsinline></video>`
: '');
const setRef = g.set_number ? `${g.set_number}` : '—';
return `<tr>
<td class="thumb-cell">${img}</td>
<td><div>${esc(g.name)}${g.is_boss ? ' <span class="pill">boss</span>' : ''}</div>
${g.display_name && g.display_name !== g.name ? `<div class="sub">↳ ${esc(g.display_name)}</div>` : ''}</td>
<td><span class="dot ${g.type}"></span> ${g.type}</td>
<td class="stars">${'★'.repeat(g.rarity)}</td>
<td>${g.health}</td><td>${g.damage}</td>
<td>${esc(g.ability || '—')}</td>
<td class="mono">${setRef}</td>
<td><span class="toggle ${g.enabled ? 'on' : 'off'}" data-id="${g.id}">${g.enabled ? '◉' : '◯'}</span></td>
<td><div class="row-actions">
<button class="edit" data-id="${g.id}">Edit</button>
<button class="danger del" data-id="${g.id}">Del</button>
</div></td>
</tr>`;
}
async function toggleGhost(id) {
const g = GHOSTS.find((x) => x.id === id);
await api(`/api/admin/ghosts/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled: !g.enabled }) });
await loadGhosts();
}
async function deleteGhost(id) {
const g = GHOSTS.find((x) => x.id === id);
if (!confirm(`Delete "${g.name}"? This also removes it from any set rosters.`)) return;
await api(`/api/admin/ghosts/${id}`, { method: 'DELETE' });
await Promise.all([loadGhosts(), loadSets()]);
}
/* ghost modal */
let editingGhostId = null;
$('#new-ghost').addEventListener('click', () => openGhost(null));
function openGhost(id) {
editingGhostId = id;
const g = id ? GHOSTS.find((x) => x.id === id) : null;
$('#gm-title').textContent = g ? 'Edit ghost' : 'New ghost';
$('#gm-name').value = g?.name || '';
$('#gm-display').value = g?.display_name || '';
$('#gm-type').value = g?.type || 'red';
$('#gm-rarity').value = g?.rarity ?? 1;
$('#gm-health').value = g?.health ?? 300;
$('#gm-damage').value = g?.damage ?? 150;
$('#gm-speed').value = g?.speed ?? 3;
$('#gm-range').value = g?.range ?? 3;
$('#gm-charge').value = g?.charge_shot ?? 2;
$('#gm-ability').value = g?.ability || '';
$('#gm-setnum').value = g?.set_number || '';
$('#gm-setname').value = g?.set_name || '';
$('#gm-boss').checked = !!g?.is_boss;
$('#gm-enabled').checked = g ? !!g.enabled : true;
$('#gm-file').value = '';
// Show the existing stored media: prefer the WebM video, else a still image
// (image_path is the GIF/PNG, or the WebP thumbnail for converted ghosts).
if (g?.webm_path) showPreview(`/uploads/${g.webm_path}`, 'video');
else if (g?.image_path) showPreview(`/uploads/${g.image_path}`, 'image');
else if (g?.webp_path) showPreview(`/uploads/${g.webp_path}`, 'image');
else hidePreview();
$('#ghost-modal').classList.remove('hidden');
}
// Swap the modal preview between an <img> and a <video> depending on media kind.
function showPreview(src, kind) {
const img = $('#gm-preview');
const vid = $('#gm-preview-vid');
if (kind === 'video') {
img.classList.add('hidden'); img.removeAttribute('src');
vid.src = src; vid.classList.remove('hidden');
vid.play?.().catch(() => {});
} else {
vid.classList.add('hidden'); vid.pause?.(); vid.removeAttribute('src');
img.src = src; img.classList.remove('hidden');
}
}
function hidePreview() {
const img = $('#gm-preview');
const vid = $('#gm-preview-vid');
img.classList.add('hidden'); img.removeAttribute('src');
vid.classList.add('hidden'); vid.pause?.(); vid.removeAttribute('src');
}
// Live local preview when a file is chosen (before upload).
$('#gm-file').addEventListener('change', () => {
const file = $('#gm-file').files[0];
if (!file) return;
const url = URL.createObjectURL(file);
const isVideo = /\.(mp4|webm)$/i.test(file.name) || file.type.startsWith('video/');
showPreview(url, isVideo ? 'video' : 'image');
});
$('#gm-save').addEventListener('click', saveGhost);
async function saveGhost() {
const payload = {
name: $('#gm-name').value.trim(),
displayName: $('#gm-display').value.trim() || $('#gm-name').value.trim(),
type: $('#gm-type').value,
rarity: +$('#gm-rarity').value,
health: +$('#gm-health').value,
damage: +$('#gm-damage').value,
speed: +$('#gm-speed').value,
range: +$('#gm-range').value,
chargeShot: +$('#gm-charge').value,
ability: $('#gm-ability').value.trim() || null,
setNumber: $('#gm-setnum').value.trim() || null,
setName: $('#gm-setname').value.trim() || null,
isBoss: $('#gm-boss').checked,
enabled: $('#gm-enabled').checked,
};
if (!payload.name) { alert('Name is required.'); return; }
let id = editingGhostId;
if (id) {
await api(`/api/admin/ghosts/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
} else {
const res = await api('/api/admin/ghosts', { method: 'POST', body: JSON.stringify(payload) });
id = (await res.json()).id;
}
const file = $('#gm-file').files[0];
if (file && id) {
const fd = new FormData();
fd.append('image', file);
await api(`/api/admin/ghosts/${id}/image`, { method: 'POST', body: fd });
}
$('#ghost-modal').classList.add('hidden');
await Promise.all([loadGhosts(), loadSets()]);
}
/* ---------- Sets ---------- */
async function loadSets() {
const res = await api('/api/admin/sets');
SETS = (await res.json()).sets;
renderSets();
}
function renderSets() {
$('#sets-list').innerHTML = SETS.map(setCard).join('') ||
'<p class="muted">No sets yet. Create one to wire a scan code to a ghost roster.</p>';
$$('#sets-list .edit-set').forEach((b) =>
b.addEventListener('click', () => openSet(+b.dataset.id)));
$$('#sets-list .del-set').forEach((b) =>
b.addEventListener('click', () => deleteSet(+b.dataset.id)));
}
function setCard(s) {
const chips = s.roster.map((r) =>
`<span class="rchip"><span class="dot ${r.type}"></span>${esc(r.name)}${r.is_boss ? ' ★' : ''}</span>`
).join('');
return `<div class="set-card ${s.enabled ? '' : 'off'}">
<div class="set-head">
<span class="code">${esc(s.code)}</span>
<span class="sname">${esc(s.set_name)}</span>
${s.set_number ? `<span class="mono muted">#${esc(s.set_number)}</span>` : ''}
<span class="grow"></span>
<button class="edit-set" data-id="${s.id}">Edit</button>
<button class="danger del-set" data-id="${s.id}">Delete</button>
</div>
<div class="set-roster">${chips || '<span class="muted">no roster</span>'}</div>
</div>`;
}
async function deleteSet(id) {
const s = SETS.find((x) => x.id === id);
if (!confirm(`Delete set "${s.set_name}" (${s.code})?`)) return;
await api(`/api/admin/sets/${id}`, { method: 'DELETE' });
await loadSets();
}
/* set modal */
let editingSetId = null;
$('#new-set').addEventListener('click', () => openSet(null));
$('#sm-roster-search').addEventListener('input', filterRosterChecklist);
function openSet(id) {
editingSetId = id;
const s = id ? SETS.find((x) => x.id === id) : null;
$('#sm-title').textContent = s ? 'Edit set' : 'New set';
$('#sm-code').value = s?.code || '';
$('#sm-setnum').value = s?.set_number || '';
$('#sm-setname').value = s?.set_name || '';
$('#sm-enabled').checked = s ? !!s.enabled : true;
// boss dropdown
const bossSel = $('#sm-boss');
bossSel.innerHTML = '<option value="">— none —</option>' +
GHOSTS.filter((g) => g.is_boss).map((g) =>
`<option value="${g.id}">${esc(g.name)}</option>`).join('');
bossSel.value = s?.boss_ghost_id || '';
// roster checklist
const selected = new Set((s?.roster || []).map((r) => r.id));
$('#sm-roster').innerHTML = GHOSTS.map((g) =>
`<label class="rcheck" data-name="${esc(g.name.toLowerCase())}">
<input type="checkbox" value="${g.id}" ${selected.has(g.id) ? 'checked' : ''}>
<span class="dot ${g.type}"></span> ${esc(g.name)} ${'★'.repeat(g.rarity)}
</label>`).join('');
$('#sm-roster-search').value = '';
$('#set-modal').classList.remove('hidden');
}
function filterRosterChecklist() {
const q = $('#sm-roster-search').value.toLowerCase();
$$('#sm-roster .rcheck').forEach((el) => {
el.style.display = !q || el.dataset.name.includes(q) ? '' : 'none';
});
}
$('#sm-save').addEventListener('click', saveSet);
async function saveSet() {
const payload = {
code: $('#sm-code').value.trim(),
setNumber: $('#sm-setnum').value.trim() || null,
setName: $('#sm-setname').value.trim(),
bossGhostId: $('#sm-boss').value ? +$('#sm-boss').value : null,
enabled: $('#sm-enabled').checked,
};
if (!payload.code || !payload.setName) { alert('Scan code and set name are required.'); return; }
let id = editingSetId;
if (id) {
await api(`/api/admin/sets/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
} else {
const res = await api('/api/admin/sets', { method: 'POST', body: JSON.stringify(payload) });
if (res.status === 409) { alert('That scan code already exists.'); return; }
id = (await res.json()).id;
}
const ghostIds = $$('#sm-roster input:checked').map((i) => +i.value);
await api(`/api/admin/sets/${id}/roster`, { method: 'PUT', body: JSON.stringify({ ghostIds }) });
$('#set-modal').classList.add('hidden');
await loadSets();
}
/* ---------- Account ---------- */
$('#cp-go').addEventListener('click', async () => {
const msg = $('#cp-msg');
msg.classList.add('hidden'); msg.classList.remove('ok');
const res = await api('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
currentPassword: $('#cp-current').value,
newPassword: $('#cp-new').value,
}),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
msg.textContent = 'Password updated.'; msg.classList.add('ok');
} else {
msg.textContent = data.error || 'Could not update password.';
}
msg.classList.remove('hidden');
$('#cp-current').value = ''; $('#cp-new').value = '';
});
/* ---------- modal close ---------- */
$$('[data-close]').forEach((b) =>
b.addEventListener('click', () => $$('.modal').forEach((m) => m.classList.add('hidden'))));
$$('.modal').forEach((m) =>
m.addEventListener('click', (e) => { if (e.target === m) m.classList.add('hidden'); }));
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}