416 lines
17 KiB
HTML
416 lines
17 KiB
HTML
<!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">Переподключение…</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, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
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) + ' · ' + 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>
|