feat: economy and polishing

This commit is contained in:
2026-09-05 19:12:28 +05:00
parent ca830b00b9
commit c56ec113cc
18 changed files with 868 additions and 42 deletions
+155
View File
@@ -0,0 +1,155 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Idle XBOCT — дашборд</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; background: #151a21; color: #dfe7f0; font: 14px/1.5 system-ui, sans-serif; }
header { padding: 14px 20px; border-bottom: 1px solid #2a3242; font-weight: 700; letter-spacing: 0.04em; }
main { max-width: 900px; margin: 0 auto; padding: 16px 20px 40px; }
section { border: 1px solid #2a3242; border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
h2 { margin: 0 0 10px; font-size: 15px; color: #b7c4d4; }
button { background: #2f3a4c; color: #dfe7f0; border: none; border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; margin-right: 8px; }
button:hover { background: #3a4759; }
input { background: #232b38; border: 1px solid #2a3242; border-radius: 6px; color: #dfe7f0; padding: 5px 8px; font-size: 13px; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #232b38; }
th { color: #8a99ab; font-weight: 600; }
.firebar { height: 10px; background: #232b38; border-radius: 5px; overflow: hidden; margin: 6px 0 10px; }
.firefill { height: 100%; background: linear-gradient(90deg, #ff8c3b, #ffd166); }
.muted { color: #6d7d90; font-size: 12px; }
.row { margin-bottom: 8px; }
#status { font: 12px monospace; color: #7ee2a8; margin-left: 12px; }
</style>
</head>
<body>
<header>Idle XBOCT — дашборд стримера <span id="status"></span></header>
<main>
<section>
<h2>Ключ администратора</h2>
<div class="row">
<input id="key" type="password" placeholder="ADMIN_KEY (если задан в .env)" style="width: 320px" />
<button id="reload">Обновить</button>
</div>
<p class="muted">Ключ нужен, только если ADMIN_KEY задан в .env. Без него дашборд открыт — держи сервер за localhost.</p>
</section>
<section>
<h2>Состояние</h2>
<div id="state" class="muted">загрузка…</div>
</section>
<section>
<h2>Костёр</h2>
<div id="fire"></div>
<div class="row">
<button data-action="campfire-reset" data-keep="1">Сбросить прогресс (сохранить вклады)</button>
<button data-action="campfire-reset" data-keep="0">Полный сброс костра</button>
</div>
</section>
<section>
<h2>Зритель</h2>
<div class="row">
<input id="viewer" placeholder="ник зрителя" style="width: 220px" />
<button data-action="viewer-reset">Сбросить зрителя</button>
</div>
<p class="muted">Стирает навыки, инвентарь и вклад в костёр. Аватар исчезнет из мира у всех клиентов.</p>
</section>
<section>
<h2>Объявления в чат</h2>
<div class="row">
<button data-action="announce" data-value="1">Включить</button>
<button data-action="announce" data-value="0">Выключить</button>
<span id="announceState" class="muted"></span>
</div>
<p class="muted">Левелапы, рост костра и подарки — пишутся ботом в чат (с троттлингом).</p>
</section>
</main>
<script>
let adminKey = localStorage.getItem('idle-admin-key') ?? '';
document.getElementById('key').value = adminKey;
document.getElementById('key').addEventListener('change', (e) => {
adminKey = e.target.value;
localStorage.setItem('idle-admin-key', adminKey);
});
async function api(path, body) {
const res = await fetch(path, {
method: body === undefined ? 'GET' : 'POST',
headers: { 'content-type': 'application/json', 'x-admin-key': adminKey },
body: body === undefined ? undefined : JSON.stringify(body),
});
const j = await res.json();
if (!res.ok) throw new Error(j.error ?? res.status);
return j;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function firePct(progress, level, thr) {
if (level >= thr.length) return 100;
const lo = thr[level - 1] ?? 0;
const hi = thr[level] ?? 1;
return Math.min(100, Math.max(0, ((progress - lo) / (hi - lo)) * 100));
}
async function refresh() {
try {
const s = await api('/api/admin/state');
document.getElementById('status').textContent = `канал ${esc(s.channel)} · зрителей ${s.viewers} · аптайм ${Math.floor(s.uptimeSec / 60)} мин`;
const thr = [0, 100, 300, 700, 1500];
document.getElementById('fire').innerHTML =
`Уровень ${s.campfire.level}${s.campfire.progress} очков` +
`<div class="firebar"><div class="firefill" style="width:${firePct(s.campfire.progress, s.campfire.level, thr)}%"></div></div>` +
(s.campfire.top.length
? '<table><tr><th>Вкладчик</th><th>Вклад</th></tr>' +
s.campfire.top.map((t) => `<tr><td style="color:${esc(t.color)}">${esc(t.name)}</td><td>${t.contributed}</td></tr>`).join('') +
'</table>'
: '<p class="muted">вкладчиков пока нет</p>');
document.getElementById('state').innerHTML =
`Тик ${s.tickMs} мс · аптайм ${s.uptimeSec} с · очередь спотлайта: ${s.spotlightQueue}` +
'<table><tr><th>Зритель</th><th>Уровень</th><th>Действие</th></tr>' +
s.top.map((v) => {
const lvl = Math.max(1, ...Object.values(v.skills).map((x) => x.level));
return `<tr><td style="color:${esc(v.color)}">${esc(v.name)}</td><td>${lvl}</td><td>${esc(v.action)}</td></tr>`;
}).join('') + '</table>';
document.getElementById('announceState').textContent = s.chatAnnounce ? 'включены' : 'выключены';
} catch (e) {
document.getElementById('status').textContent = 'ошибка: ' + e.message;
}
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
try {
if (action === 'campfire-reset') {
await api('/api/admin/campfire/reset', { keepLedger: btn.dataset.keep === '1' });
} else if (action === 'viewer-reset') {
const name = document.getElementById('viewer').value.trim();
if (!name) return;
await api('/api/admin/viewer/reset', { name });
} else if (action === 'announce') {
await api('/api/admin/announce', { enabled: btn.dataset.value === '1' });
}
await refresh();
} catch (err) {
alert('Ошибка: ' + err.message);
}
});
document.getElementById('reload').addEventListener('click', refresh);
refresh();
setInterval(refresh, 15000);
</script>
</body>
</html>