qkeeper/static/index.html
Aleksandr Berkuta 5806fe84c4 init commit
2026-03-27 20:27:56 +03:00

416 lines
17 KiB
HTML
Raw Permalink 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="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>Очередь…</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f0f0f0;color:#111;min-height:100vh}
header{background:#fff;border-bottom:1px solid #e0e0e0;padding:14px 16px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:10}
#ht{display:flex;align-items:center;gap:8px}
header h1{font-size:1.1rem;font-weight:700;letter-spacing:-.01em}
#ib{font-size:.72rem;color:#555;text-decoration:none;font-weight:600;background:#f3f4f6;border:1.5px solid #d1d5db;border-radius:6px;padding:4px 9px;line-height:1;-webkit-tap-highlight-color:transparent}
#ib:hover{background:#e5e7eb;border-color:#9ca3af}
#qr-wrap{text-align:center}
#qr-img{width:44px;height:44px;border-radius:6px;display:block;cursor:pointer}
#qr-lbl{font-size:.6rem;color:#999;margin-top:2px;letter-spacing:.02em}
main{max-width:520px;margin:0 auto;padding:12px 12px 32px}
/* restore banner */
#rb{background:#fef9c3;border:1px solid #fde047;border-radius:10px;padding:14px 16px;margin-bottom:12px}
#rb p{font-size:.9rem;color:#713f12;font-weight:500;margin-bottom:10px}
#rb button{background:#111;color:#fff;border:none;border-radius:8px;padding:10px 18px;font-size:.9rem;font-weight:600;cursor:pointer;-webkit-tap-highlight-color:transparent}
/* join section */
#js{background:#fff;border-radius:10px;padding:16px;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
#jh{font-size:.9rem;color:#555;margin-bottom:12px;font-weight:500}
#jb{width:100%;background:#111;color:#fff;border:none;border-radius:8px;padding:14px;font-size:1rem;font-weight:700;cursor:pointer;letter-spacing:.01em;-webkit-tap-highlight-color:transparent}
#jb:active{background:#333}
#jf{margin-top:4px}
#ni{width:100%;border:1.5px solid #ccc;border-radius:8px;padding:12px 14px;font-size:1rem;margin-bottom:10px;outline:none;-webkit-appearance:none}
#ni:focus{border-color:#111}
.row{display:flex;gap:8px}
.row button{flex:1;padding:12px;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer;border:1.5px solid transparent;-webkit-tap-highlight-color:transparent}
/* queue */
#ql-hdr{font-size:.75rem;font-weight:600;color:#999;text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;padding-left:2px}
.empty{text-align:center;padding:40px 20px;color:#aaa;font-size:.95rem}
/* entry card */
.card{background:#fff;border-radius:10px;margin-bottom:10px;padding:14px 14px 10px;box-shadow:0 1px 3px rgba(0,0,0,.07);transition:opacity .25s}
.card.mine{border:2px solid #2563eb}
.card.dim{opacity:.45}
.top{display:flex;align-items:center;gap:12px}
.num{width:36px;height:36px;border-radius:50%;background:#111;color:#fff;display:flex;align-items:center;justify-content:center;font-size:.85rem;font-weight:700;flex-shrink:0}
.num.me{background:#2563eb}
.inf{flex:1;min-width:0}
.nm{font-size:.95rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:6px}
.you{font-size:.7rem;background:#eff6ff;color:#2563eb;border-radius:4px;padding:1px 6px;font-weight:700;flex-shrink:0}
.meta{font-size:.78rem;color:#888;margin-top:2px}
.wi{color:#2563eb;font-weight:500}
/* pending info */
.pi{background:#fef9c3;border-radius:7px;padding:8px 12px;margin-top:8px;font-size:.82rem;color:#713f12;font-weight:500}
.pi span{font-weight:700;font-size:.9rem}
/* vote count */
.vc{font-size:.78rem;color:#b45309;background:#fffbeb;border-radius:5px;padding:3px 8px;margin-top:6px;display:inline-block;font-weight:600}
/* action buttons */
.acts{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.btn{padding:9px 14px;border-radius:7px;font-size:.82rem;font-weight:600;cursor:pointer;border:1.5px solid transparent;-webkit-tap-highlight-color:transparent}
.btn:disabled{opacity:.4;cursor:default}
.bd{background:#fee2e2;color:#dc2626;border-color:#fca5a5}
.bw{background:#fef9c3;color:#854d0e;border-color:#fde047}
.bn{background:#f3f4f6;color:#374151;border-color:#d1d5db}
/* inline confirm */
.cf{background:#fee2e2;border-radius:8px;padding:10px 12px;margin-top:8px}
.cf p{font-size:.85rem;color:#991b1b;font-weight:500;margin-bottom:8px}
.cf .row button{padding:9px}
.cfok{background:#dc2626;color:#fff;border-color:#dc2626}
.cfno{background:#f3f4f6;color:#374151;border-color:#d1d5db}
/* toast */
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:10px 18px;border-radius:8px;font-size:.85rem;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw;text-align:center;z-index:100}
#toast.show{opacity:1}
/* disconnected banner */
#disc{display:none;position:fixed;top:0;left:0;right:0;background:#dc2626;color:#fff;font-size:.82rem;font-weight:600;text-align:center;padding:6px;z-index:20}
</style>
</head>
<body>
<div id="disc">Переподключение&hellip;</div>
<header>
<div id="ht">
<h1 id="qn">Очередь…</h1>
<a href="/help.html" target="_blank" id="ib">как пользоваться</a>
</div>
<div id="qr-wrap">
<a id="qr-lnk" href="#" target="_blank" title="Показать QR-код">
<img id="qr-img" src="" alt="QR-код">
</a>
<div id="qr-lbl">поделиться</div>
</div>
</header>
<main>
<div id="rb" hidden>
<p id="rb-txt"></p>
<button onclick="doRestore()">Вернуть моё место</button>
</div>
<div id="js">
<p id="jh"></p>
<button id="jb" onclick="showForm()">Взять номер</button>
<div id="jf" hidden>
<input id="ni" type="text" placeholder="Ваше имя или псевдоним — необязательно"
maxlength="50" autocomplete="off" autocorrect="off">
<div class="row">
<button class="btn bn" onclick="doJoin('')">Пропустить</button>
<button class="btn bd" onclick="doJoin(V('ni'))">Встать</button>
</div>
</div>
</div>
<div id="ql-hdr">В очереди</div>
<div id="ql"></div>
</main>
<div id="toast"></div>
<script>
'use strict';
// XSS SAFETY NOTE
// The only user-supplied string that reaches other browsers is entry.name,
// which is sanitised server-side (max 50 runes, no null bytes) and HTML-escaped
// on the client via esc() before any innerHTML insertion.
// All other dynamic values injected into HTML are server-generated hex IDs
// (safe in attribute values) or integers (always numeric, never user input).
// Queue ID is the third path segment: /q/{id}
const queueId = location.pathname.split('/')[2] || '';
const TK = 'qt:' + queueId; // localStorage key for token, scoped per queue
// Set QR link/src now so they're correct before first state arrives.
const qrBase = '/q/' + queueId + '/qr.png';
document.getElementById('qr-img').src = qrBase;
document.getElementById('qr-lnk').href = qrBase;
// ---- State ----
const S = {
entries: [], avgDuration: null,
myEntryId: null,
myToken: localStorage.getItem(TK) || '',
showForm: false,
confirmId: null // own-entry confirm = entryId; mark-done confirm = "mark:<id>"
};
// ---- WebSocket ----
let ws;
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(proto + '://' + location.host + '/q/' + queueId + '/ws');
ws.onopen = () => {
document.getElementById('disc').style.display = 'none';
ws.send(JSON.stringify({type: 'init', token: S.myToken}));
};
ws.onmessage = e => onMsg(JSON.parse(e.data));
ws.onclose = () => {
document.getElementById('disc').style.display = 'block';
setTimeout(connect, 2000);
};
ws.onerror = () => ws.close();
}
function send(obj) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
}
function onMsg(m) {
if (m.type === 'hello') {
S.myEntryId = m.yourEntryId || null;
render();
} else if (m.type === 'joined') {
S.myToken = m.yourToken;
S.myEntryId = m.yourEntryId;
localStorage.setItem(TK, m.yourToken);
render();
} else if (m.type === 'state') {
S.entries = m.entries;
S.avgDuration = m.avgDuration;
if (m.name) {
document.getElementById('qn').textContent = m.name;
document.title = m.name;
}
// Entry may have been removed externally (voted out, timer expired).
if (S.myEntryId && !m.entries.find(e => e.id === S.myEntryId)) {
S.myEntryId = null;
S.myToken = '';
localStorage.removeItem(TK);
}
render();
} else if (m.type === 'error' && m.reason === 'already_voting') {
toast('Вы уже голосуете за удаление №' + m.onNumber);
}
}
// ---- Actions ----
function showForm() {
S.showForm = true;
render();
setTimeout(() => document.getElementById('ni').focus(), 50);
}
function hideForm() { S.showForm = false; render(); }
function doJoin(name) { send({type: 'join', name}); hideForm(); }
function doDone() { S.confirmId = S.myEntryId; render(); }
function cancelConfirm() { S.confirmId = null; render(); }
function confirmDone() {
send({type: 'done'});
S.myEntryId = null;
S.myToken = '';
localStorage.removeItem(TK);
S.confirmId = null;
render();
}
function markDone(id) { S.confirmId = 'mark:' + id; render(); }
function confirmMark(id) {
send({type: 'mark_done', entryId: id});
S.confirmId = null;
render();
}
function voteRemove(id) { send({type: 'vote_remove', entryId: id}); }
function doRestore() { send({type: 'restore'}); }
// ---- Countdown ----
let ticker;
function startTicker() { if (!ticker) ticker = setInterval(tick, 1000); }
function stopTicker() { clearInterval(ticker); ticker = null; }
function tick() {
document.querySelectorAll('[data-pa]').forEach(el => {
const rem = 60 - Math.floor((Date.now() - +el.dataset.pa) / 1000);
el.textContent = rem > 0 ? rem + 'с' : 'удаление\u2026';
});
}
// ---- Render helpers ----
// esc() HTML-encodes a string before inserting into innerHTML.
function esc(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function V(id) { return document.getElementById(id).value.trim(); }
function fmt(ms) {
const d = new Date(ms);
const p = n => String(n).padStart(2, '0');
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
function fmtWait(secs) {
return secs < 90 ? '~' + Math.round(secs) + ' с' : '~' + Math.round(secs / 60) + ' мин';
}
function ruPeople(n) {
const m = n % 100, k = n % 10;
if (m >= 11 && m <= 19) return 'человек';
if (k === 1) return 'человек';
if (k >= 2 && k <= 4) return 'человека';
return 'человек';
}
function thresh(n) { return Math.min(3, Math.max(1, Math.ceil(n / 2))); }
// ---- Main render ----
function render() {
const entries = S.entries;
const myE = entries.find(e => e.id === S.myEntryId) || null;
const inQ = !!myE;
// Restore banner (shown when own entry is in pending_removal)
const rb = document.getElementById('rb');
if (myE && myE.status === 'pending_removal') {
const by = (myE.pendingBy || []).map(n => '#' + n).join(', ');
const verb = (myE.pendingBy || []).length > 1
? ' проголосовали за ваше удаление' : ' отметил вас как обслуженного';
// rb-txt is set via textContent — safe from XSS regardless.
document.getElementById('rb-txt').textContent = by + verb + ' \u2014 это ошибка?';
rb.hidden = false;
} else {
rb.hidden = true;
}
// Join section
const js = document.getElementById('js');
if (inQ) {
js.hidden = true;
} else {
js.hidden = false;
// jh uses textContent — safe.
document.getElementById('jh').textContent =
entries.length === 0
? 'Очередь пуста \u2014 будьте первым!'
: 'В очереди: ' + entries.length + ' ' + ruPeople(entries.length);
document.getElementById('jb').hidden = S.showForm;
document.getElementById('jf').hidden = !S.showForm;
}
// Queue list
const ql = document.getElementById('ql');
if (entries.length === 0) {
ql.innerHTML = '<p class="empty">Отсканируйте QR-код, чтобы встать в очередь</p>';
stopTicker();
return;
}
let hasPending = false;
// Which entry is this user currently voting to remove (if any)?
let myVoteOn = 0;
if (myE) {
for (const e of entries) {
if ((e.pendingBy || []).includes(myE.number)) { myVoteOn = e.number; break; }
}
}
// Build HTML for each entry card.
// IMPORTANT: all user-supplied strings (only e.name) are passed through esc().
// e.id is server-generated hex (safe in attribute positions).
// e.number is an integer (safe).
// pendingBy values are integers (safe).
const parts = entries.map((e, idx) => {
const mine = e.id === S.myEntryId;
const pending = e.status === 'pending_removal';
const first = idx === 0;
if (pending) hasPending = true;
// Wait estimate line
let waitHtml;
if (idx === 0) {
waitHtml = '<span class="wi">первый</span>';
} else if (S.avgDuration != null) {
waitHtml = '<span class="wi">ожид. ' + fmtWait(S.avgDuration * idx) + '</span>';
} else {
waitHtml = '<span class="wi">ожид. ?</span>';
}
// Pending / vote-count info block
let pendHtml = '';
if (pending && e.pendingAt) {
const rem = Math.max(0, 60 - Math.floor((Date.now() - e.pendingAt) / 1000));
// pendingBy contains integers — no esc() needed, but we join them safely.
const by = (e.pendingBy || []).map(n => '#' + n).join(', ');
pendHtml = '<div class="pi">Отметил ' + by +
' \u2014 удаление через <span data-pa="' + e.pendingAt + '">' + rem + 'с</span></div>';
} else if (!pending && (e.pendingBy || []).length > 0) {
const t = thresh(entries.length);
pendHtml = '<div class="vc">' + e.pendingBy.length + '/' + t + ' за удаление</div>';
}
// Action area (inline confirm or buttons)
let actHtml = '';
if (S.confirmId === e.id) {
// Inline confirm for own "I'm done"
actHtml = '<div class="cf"><p>Покинуть очередь?</p>' +
'<div class="row">' +
'<button class="btn cfno" onclick="cancelConfirm()">Отмена</button>' +
'<button class="btn cfok" onclick="confirmDone()">Ухожу</button>' +
'</div></div>';
} else if (S.confirmId === 'mark:' + e.id) {
// Inline confirm for "Mark as done" on #1
actHtml = '<div class="cf"><p>Отметить №' + e.number + ' как ушедшего?</p>' +
'<div class="row">' +
'<button class="btn cfno" onclick="cancelConfirm()">Отмена</button>' +
'<button class="btn cfok" onclick="confirmMark(\'' + e.id + '\')">Отметить</button>' +
'</div></div>';
} else if (mine && !pending) {
actHtml = '<div class="acts"><button class="btn bd" onclick="doDone()">Ухожу</button></div>';
} else if (!mine && first && !pending && inQ) {
actHtml = '<div class="acts"><button class="btn bw" onclick="markDone(\'' + e.id + '\')">Отметить ушедшим</button></div>';
} else if (!mine && !first && !pending && inQ) {
const alreadyVotedThis = (e.pendingBy || []).includes(myE.number);
if (alreadyVotedThis) {
actHtml = '<div class="acts"><button class="btn bn" disabled>Голосовать за удаление</button></div>';
} else if (myVoteOn !== 0) {
actHtml = '<div class="acts"><button class="btn bn" disabled>Голосовать за удаление</button></div>';
} else {
const t = thresh(entries.length);
const v = (e.pendingBy || []).length;
const label = v > 0 ? 'Голосовать за удаление (' + v + '/' + t + ')' : 'Голосовать за удаление';
actHtml = '<div class="acts"><button class="btn bn" onclick="voteRemove(\'' + e.id + '\')">' +
label + '</button></div>';
}
}
// e.name is user-supplied — MUST use esc().
const nameStr = e.name ? esc(e.name) : '#' + e.number;
const youBadge = mine ? '<span class="you">вы</span>' : '';
return '<div class="card' + (mine ? ' mine' : '') + (pending ? ' dim' : '') + '">' +
'<div class="top">' +
'<div class="num' + (mine ? ' me' : '') + '">' + e.number + '</div>' +
'<div class="inf">' +
'<div class="nm">' + nameStr + youBadge + '</div>' +
'<div class="meta">' + fmt(e.joinedAt) + ' &nbsp;&middot;&nbsp; ' + waitHtml + '</div>' +
'</div>' +
'</div>' +
pendHtml + actHtml +
'</div>';
});
ql.innerHTML = parts.join('');
hasPending ? startTicker() : stopTicker();
}
// ---- Toast ----
let toastT;
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg; // textContent is always XSS-safe
el.classList.add('show');
clearTimeout(toastT);
toastT = setTimeout(() => el.classList.remove('show'), 3000);
}
// ---- Boot ----
connect();
</script>
</body>
</html>