feat: economy and polishing
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user