148 lines
6.3 KiB
HTML
148 lines
6.3 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}
|
||
header h1{font-size:1.2rem;font-weight:700}
|
||
header p{font-size:.85rem;color:#888;margin-top:2px}
|
||
main{max-width:520px;margin:0 auto;padding:16px 12px 48px}
|
||
section{background:#fff;border-radius:10px;padding:18px 16px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
|
||
h2{font-size:1rem;font-weight:700;margin-bottom:12px}
|
||
.qlist{list-style:none}
|
||
.qlist li{border-bottom:1px solid #f0f0f0;padding:10px 0}
|
||
.qlist li:last-child{border-bottom:none}
|
||
.qlist a{text-decoration:none;color:#111;font-weight:600;font-size:.95rem}
|
||
.qlist a:hover{text-decoration:underline}
|
||
.qmeta{font-size:.78rem;color:#999;margin-top:2px}
|
||
.empty{color:#aaa;font-size:.9rem;padding:4px 0}
|
||
form{display:flex;gap:8px;flex-wrap:wrap}
|
||
#qname{flex:1;min-width:0;border:1.5px solid #ccc;border-radius:8px;padding:11px 14px;font-size:1rem;outline:none;-webkit-appearance:none}
|
||
#qname:focus{border-color:#111}
|
||
form button{background:#111;color:#fff;border:none;border-radius:8px;padding:11px 20px;font-size:.9rem;font-weight:700;cursor:pointer;white-space:nowrap;-webkit-tap-highlight-color:transparent}
|
||
form button:active{background:#333}
|
||
ol{padding-left:20px}
|
||
ol li{margin-bottom:8px;font-size:.9rem;line-height:1.5;color:#444}
|
||
.note{font-size:.82rem;color:#666;line-height:1.5;background:#fef9c3;border-radius:8px;padding:14px 16px;border:1px solid #fde047}
|
||
.note p+p{margin-top:8px}
|
||
footer{text-align:center;color:#bbb;font-size:.78rem;padding:8px 16px 24px}
|
||
footer a{color:#999}
|
||
#err{color:#dc2626;font-size:.85rem;margin-top:6px;display:none}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Электронная очередь</h1>
|
||
<p>Самоорганизуйтесь без бумажных талончиков</p>
|
||
</header>
|
||
<main>
|
||
|
||
<section>
|
||
<h2>Активные очереди</h2>
|
||
<ul class="qlist" id="ql"><li class="empty">Загрузка…</li></ul>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Создать очередь</h2>
|
||
<form id="nf" onsubmit="createQueue(event)">
|
||
<input id="qname" type="text" placeholder="Название, напр. «Кабинет 428»"
|
||
maxlength="100" autocomplete="off" autocorrect="off">
|
||
<button type="submit">Создать</button>
|
||
</form>
|
||
<div id="err">Не удалось создать очередь, попробуйте ещё раз</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Как пользоваться</h2>
|
||
<ol>
|
||
<li>Найдите нужную очередь в списке выше или создайте новую.</li>
|
||
<li>Откройте ссылку или отсканируйте QR-код на странице очереди.</li>
|
||
<li>Нажмите «Взять номер» — приложение покажет вашу позицию и ориентировочное время ожидания.</li>
|
||
<li>Когда подойдёт очередь, отметьте себя как ушедшего кнопкой «Ухожу».</li>
|
||
</ol>
|
||
</section>
|
||
|
||
<div class="note">
|
||
<p><strong>Это неофициальный инструмент самоорганизации</strong>, сделанный энтузиастом, которому надоело стоять в живых очередях. Никаких гарантий бесперебойной работы не даётся.</p>
|
||
<p>Вопросы и пожелания: <a href="mailto:line.keeper@berkuta.xyz">line.keeper@berkuta.xyz</a></p>
|
||
</div>
|
||
|
||
</main>
|
||
<footer>Очереди без записи, без регистрации</footer>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
// XSS SAFETY NOTE
|
||
// All user-supplied strings (q.name only) are passed through esc() before
|
||
// any innerHTML insertion. q.id is server-generated hex (safe in href).
|
||
// q.count is an integer. q.createdAt is an integer (Unix ms). No raw
|
||
// user content is inserted unsanitised.
|
||
|
||
function fmtDate(ms) {
|
||
const d = new Date(ms);
|
||
return d.toLocaleDateString('ru-RU', {day:'numeric',month:'long',year:'numeric'});
|
||
}
|
||
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 'человек';
|
||
}
|
||
// esc() HTML-encodes a string before inserting into innerHTML.
|
||
function esc(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
async function loadQueues() {
|
||
const ul = document.getElementById('ql');
|
||
try {
|
||
const res = await fetch('/queues');
|
||
const list = await res.json();
|
||
if (!list || list.length === 0) {
|
||
ul.innerHTML = '<li class="empty">Пока нет ни одной очереди — создайте первую!</li>';
|
||
return;
|
||
}
|
||
// q.id is hex (safe in href); q.name is user-supplied — esc() applied.
|
||
// q.count is integer; fmtDate receives integer ms — no user data.
|
||
ul.innerHTML = list.map(q => {
|
||
const cnt = q.count > 0 ? q.count + '\u00a0' + ruPeople(q.count) : 'пусто';
|
||
return '<li>' +
|
||
'<a href="/q/' + esc(q.id) + '">' + esc(q.name) + '</a>' +
|
||
'<div class="qmeta">Создана ' + fmtDate(q.createdAt) + ' · ' + cnt + '</div>' +
|
||
'</li>';
|
||
}).join('');
|
||
} catch(e) {
|
||
ul.innerHTML = '<li class="empty">Не удалось загрузить список</li>';
|
||
}
|
||
}
|
||
|
||
function createQueue(e) {
|
||
e.preventDefault();
|
||
const name = document.getElementById('qname').value.trim();
|
||
const err = document.getElementById('err');
|
||
err.style.display = 'none';
|
||
fetch('/new', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: 'name=' + encodeURIComponent(name),
|
||
redirect: 'follow'
|
||
}).then(res => {
|
||
if (res.redirected) {
|
||
location.href = res.url;
|
||
} else {
|
||
err.style.display = 'block';
|
||
}
|
||
}).catch(() => { err.style.display = 'block'; });
|
||
}
|
||
|
||
loadQueues();
|
||
</script>
|
||
</body>
|
||
</html>
|