Files
queue-app/public/index.html
gm 960c829744 feat: 표시판 호출 시 띵동 효과음 추가
Web Audio API로 E5→C5 두음 벨소리 합성 (외부 파일 불필요).
표시판 화면에 소리 켜짐/꺼짐 토글 버튼 추가.
AudioContext는 역할 선택 클릭 시 초기화 (브라우저 autoplay 정책 대응).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:38:10 +09:00

507 lines
17 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>대기 번호표</title>
<style>
:root {
--radius: 16px;
--shadow: 0 4px 24px rgba(0,0,0,.12);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
background: #f0f2f5;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
}
/* ── SCREENS ── */
.screen {
display: none;
width: 100%;
min-height: 100dvh;
padding: 24px 16px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.screen.active { display: flex; }
/* ── CARD ── */
.card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 32px 28px;
width: 100%;
max-width: 420px;
text-align: center;
}
.card h1 { font-size: 1.5rem; font-weight: 700; color: #1a1a2e; margin-bottom: 8px; }
.card p { color: #666; font-size: .95rem; margin-bottom: 20px; line-height: 1.5; }
/* ── INPUTS ── */
input[type="text"], input[type="password"] {
width: 100%;
padding: 14px 16px;
font-size: 1.1rem;
border: 2px solid #e0e0e0;
border-radius: 10px;
outline: none;
transition: border .2s;
text-align: center;
letter-spacing: 4px;
}
input:focus { border-color: #4f46e5; }
.error-msg { color: #e53e3e; font-size: .85rem; margin-top: 8px; min-height: 1.2em; }
/* ── BUTTONS ── */
.btn {
display: block;
width: 100%;
padding: 16px;
font-size: 1.05rem;
font-weight: 600;
border: none;
border-radius: 12px;
cursor: pointer;
transition: transform .1s, opacity .2s;
margin-top: 12px;
}
.btn:active { transform: scale(.97); }
.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.btn-primary { background: #4f46e5; color: #fff; }
.btn-success { background: #22c55e; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-secondary { background: #f1f5f9; color: #333; }
/* ── ROLE CARDS ── */
.role-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
width: 100%;
max-width: 420px;
}
.role-card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 18px;
border: 2px solid transparent;
transition: border .15s, transform .1s;
}
.role-card:hover { border-color: #4f46e5; }
.role-card:active { transform: scale(.98); }
.role-icon { font-size: 2.4rem; flex-shrink: 0; }
.role-text h3 { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
.role-text p { font-size: .85rem; color: #777; margin-top: 2px; }
/* ── STAT BADGE ── */
.stat-row {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 4px;
}
.stat {
background: #f8fafc;
border-radius: 10px;
padding: 12px 18px;
text-align: center;
flex: 1;
min-width: 90px;
}
.stat-val { font-size: 1.8rem; font-weight: 800; color: #4f46e5; }
.stat-lbl { font-size: .75rem; color: #888; margin-top: 2px; }
/* ── ADMIN SCREEN ── */
#screen-admin { background: #eef2ff; }
#screen-admin .card h1 { color: #4f46e5; }
.admin-current {
font-size: 4rem;
font-weight: 900;
color: #1a1a2e;
margin: 12px 0;
line-height: 1;
}
/* ── ISSUE SCREEN ── */
#screen-issue { background: #f0fdf4; }
.my-ticket-box {
background: linear-gradient(135deg, #22c55e, #16a34a);
border-radius: var(--radius);
padding: 24px;
color: #fff;
text-align: center;
margin: 12px 0;
}
.my-ticket-box .tnum { font-size: 4.5rem; font-weight: 900; line-height: 1; }
.my-ticket-box .tlbl { font-size: .9rem; opacity: .85; margin-top: 4px; }
/* ── DISPLAY SCREEN ── */
#screen-display {
background: #0f172a;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
padding: 0;
}
.display-label {
color: #94a3b8;
font-size: 1.1rem;
letter-spacing: 4px;
text-transform: uppercase;
font-weight: 600;
}
.display-num {
color: #f8fafc;
font-size: clamp(6rem, 25vw, 16rem);
font-weight: 900;
line-height: 1;
font-variant-numeric: tabular-nums;
transition: color .2s;
}
.display-num.flash {
animation: flash-anim .6s ease-out;
}
@keyframes flash-anim {
0% { color: #facc15; transform: scale(1.05); }
60% { color: #facc15; }
100% { color: #f8fafc; transform: scale(1); }
}
.display-sub {
color: #475569;
font-size: 1rem;
margin-top: 4px;
}
.pin-badge {
position: fixed;
top: 16px; right: 16px;
background: rgba(255,255,255,.08);
color: #94a3b8;
font-size: .75rem;
padding: 4px 10px;
border-radius: 20px;
letter-spacing: 2px;
}
#screen-display .btn {
position: fixed;
bottom: 20px;
width: auto;
padding: 10px 20px;
font-size: .85rem;
background: rgba(255,255,255,.08);
color: #94a3b8;
border-radius: 20px;
}
/* ── HEADER PIN ── */
.session-header {
font-size: .8rem;
color: #94a3b8;
letter-spacing: 2px;
margin-bottom: -8px;
}
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 8px 0; }
</style>
</head>
<body>
<!-- ══════════════════ PIN 입력 ══════════════════ -->
<div id="screen-pin" class="screen active">
<div class="card">
<div style="font-size:2.5rem;margin-bottom:12px">🎫</div>
<h1>대기 번호표</h1>
<p>세션 PIN을 입력하세요.<br>없으면 새 PIN을 만들어 접속하세요.</p>
<input type="password" id="pin-input" placeholder="PIN 입력 (4자리 이상)"
maxlength="12" inputmode="numeric" autocomplete="off">
<div class="error-msg" id="pin-error"></div>
<button class="btn btn-primary" id="pin-btn">접속</button>
</div>
</div>
<!-- ══════════════════ 역할 선택 ══════════════════ -->
<div id="screen-role" class="screen">
<div class="session-header" id="role-pin-label">PIN: ----</div>
<div class="role-grid">
<div class="role-card" id="role-admin">
<div class="role-icon">⚙️</div>
<div class="role-text">
<h3>관리자</h3>
<p>번호 호출 및 초기화 관리</p>
</div>
</div>
<div class="role-card" id="role-issue">
<div class="role-icon">🎟️</div>
<div class="role-text">
<h3>번호표 발급기</h3>
<p>대기 번호표 발급 키오스크</p>
</div>
</div>
<div class="role-card" id="role-display">
<div class="role-icon">📺</div>
<div class="role-text">
<h3>번호 표시판</h3>
<p>현재 호출 번호 대형 화면 표시</p>
</div>
</div>
</div>
</div>
<!-- ══════════════════ 관리자 ══════════════════ -->
<div id="screen-admin" class="screen">
<div class="card" style="max-width:460px">
<p class="session-header" id="admin-pin-label">PIN: ----</p>
<h1>⚙️ 관리자</h1>
<p>현재 호출 번호</p>
<div class="admin-current" id="admin-current">-</div>
<hr class="divider">
<div class="stat-row">
<div class="stat">
<div class="stat-val" id="admin-waiting">0</div>
<div class="stat-lbl">대기 중</div>
</div>
<div class="stat">
<div class="stat-val" id="admin-issued">0</div>
<div class="stat-lbl">발급 총계</div>
</div>
</div>
<button class="btn btn-primary" id="admin-next-btn">다음 호출 ▶</button>
<button class="btn btn-danger" id="admin-reset-btn" style="margin-top:8px">번호 초기화</button>
<button class="btn btn-secondary" id="admin-back-btn" style="margin-top:8px;font-size:.85rem">역할 변경</button>
</div>
</div>
<!-- ══════════════════ 발급기 ══════════════════ -->
<div id="screen-issue" class="screen">
<div class="card">
<p class="session-header" id="issue-pin-label">PIN: ----</p>
<h1>🎟️ 번호표 발급</h1>
<div class="stat-row" style="margin-bottom:8px">
<div class="stat">
<div class="stat-val" id="issue-waiting">0</div>
<div class="stat-lbl">현재 대기 인원</div>
</div>
<div class="stat">
<div class="stat-val" id="issue-called">-</div>
<div class="stat-lbl">현재 호출 번호</div>
</div>
</div>
<div id="issue-ticket-box" style="display:none" class="my-ticket-box">
<div class="tlbl">내 번호표</div>
<div class="tnum" id="issue-my-num">-</div>
<div class="tlbl" id="issue-my-wait"></div>
</div>
<button class="btn btn-success" id="issue-btn">번호표 뽑기</button>
<button class="btn btn-secondary" id="issue-back-btn" style="margin-top:8px;font-size:.85rem">역할 변경</button>
</div>
</div>
<!-- ══════════════════ 표시판 ══════════════════ -->
<div id="screen-display" class="screen">
<div class="pin-badge" id="display-pin-label">----</div>
<div class="display-label">현재 호출 번호</div>
<div class="display-num" id="display-num">-</div>
<div class="display-sub" id="display-sub">대기 중 0명</div>
<button class="btn" id="display-mute-btn" style="bottom:60px">🔔 소리 켜짐</button>
<button class="btn" id="display-back-btn">역할 변경</button>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
let currentPin = '';
let currentRole = '';
let myTicketNumber = null;
let lastCalledCount = 0;
// ── 소리 ──────────────────────────────────────
let audioCtx = null;
let soundMuted = false;
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function playDingDong() {
if (soundMuted || !audioCtx) return;
if (audioCtx.state === 'suspended') audioCtx.resume();
const t = audioCtx.currentTime;
function bell(freq, start, dur, vol) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, start);
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(vol, start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.start(start);
osc.stop(start + dur + 0.05);
}
// 띵 (E5 + 옥타브 배음)
bell(659, t, 1.1, 0.40);
bell(1318, t, 1.1, 0.12);
// 동 (C5 + 옥타브 배음) — 0.45초 후
bell(523, t + 0.45, 1.4, 0.40);
bell(1046, t + 0.45, 1.4, 0.12);
}
document.getElementById('display-mute-btn').addEventListener('click', () => {
soundMuted = !soundMuted;
document.getElementById('display-mute-btn').textContent =
soundMuted ? '🔇 소리 꺼짐' : '🔔 소리 켜짐';
});
// ── 유틸 ──────────────────────────────────────
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function fmtNum(n) { return n > 0 ? String(n) : '-'; }
// ── PIN 화면 ──────────────────────────────────
const pinInput = document.getElementById('pin-input');
const pinError = document.getElementById('pin-error');
document.getElementById('pin-btn').addEventListener('click', tryJoin);
pinInput.addEventListener('keydown', e => { if (e.key === 'Enter') tryJoin(); });
function tryJoin() {
const v = pinInput.value.trim();
if (v.length < 4) { pinError.textContent = 'PIN은 4자리 이상이어야 합니다.'; return; }
pinError.textContent = '';
socket.emit('join', { pin: v });
}
socket.on('join_error', msg => { pinError.textContent = msg; });
socket.on('join_ok', state => {
currentPin = pinInput.value.trim();
document.getElementById('role-pin-label').textContent = `PIN: ${currentPin}`;
document.getElementById('admin-pin-label').textContent = `PIN: ${currentPin}`;
document.getElementById('issue-pin-label').textContent = `PIN: ${currentPin}`;
document.getElementById('display-pin-label').textContent = `PIN: ${currentPin}`;
applyState(state);
showScreen('screen-role');
});
// ── 역할 선택 ──────────────────────────────────
document.getElementById('role-admin').addEventListener('click', () => {
currentRole = 'admin'; showScreen('screen-admin');
});
document.getElementById('role-issue').addEventListener('click', () => {
currentRole = 'issue'; myTicketNumber = null;
document.getElementById('issue-ticket-box').style.display = 'none';
document.getElementById('issue-btn').disabled = false;
document.getElementById('issue-btn').textContent = '번호표 뽑기';
showScreen('screen-issue');
});
document.getElementById('role-display').addEventListener('click', () => {
currentRole = 'display';
initAudio(); // 사용자 인터랙션 직후 AudioContext 생성 (autoplay 정책)
showScreen('screen-display');
});
// 역할 변경
document.getElementById('admin-back-btn').addEventListener('click', () => showScreen('screen-role'));
document.getElementById('issue-back-btn').addEventListener('click', () => showScreen('screen-role'));
document.getElementById('display-back-btn').addEventListener('click', () => showScreen('screen-role'));
// ── 관리자 버튼 ──────────────────────────────────
document.getElementById('admin-next-btn').addEventListener('click', () => {
socket.emit('call_next');
});
document.getElementById('admin-reset-btn').addEventListener('click', () => {
if (confirm('번호를 초기화하시겠습니까?\n발급된 번호가 모두 0으로 돌아갑니다.')) {
socket.emit('reset');
}
});
// ── 발급 버튼 ──────────────────────────────────
document.getElementById('issue-btn').addEventListener('click', () => {
socket.emit('issue_ticket');
});
// ── 상태 갱신 ──────────────────────────────────
function applyState(s) {
// 관리자
document.getElementById('admin-current').textContent = fmtNum(s.calledCount);
document.getElementById('admin-waiting').textContent = s.waitingCount;
document.getElementById('admin-issued').textContent = s.issuedCount;
document.getElementById('admin-next-btn').disabled = s.waitingCount === 0;
// 발급기
document.getElementById('issue-waiting').textContent = s.waitingCount;
document.getElementById('issue-called').textContent = fmtNum(s.calledCount);
if (myTicketNumber !== null) {
const ahead = myTicketNumber - s.calledCount;
document.getElementById('issue-my-wait').textContent =
ahead > 0 ? `앞에 ${ahead}명 대기 중` : '곧 호출됩니다!';
}
// 표시판
const displayEl = document.getElementById('display-num');
const newVal = fmtNum(s.calledCount);
if (displayEl.textContent !== newVal) {
displayEl.classList.remove('flash');
void displayEl.offsetWidth; // reflow
displayEl.textContent = newVal;
if (s.calledCount > 0) {
displayEl.classList.add('flash');
if (currentRole === 'display') playDingDong();
}
}
lastCalledCount = s.calledCount;
document.getElementById('display-sub').textContent = `대기 중 ${s.waitingCount}`;
}
socket.on('state_update', applyState);
socket.on('ticket_issued', ({ ticketNumber, waitingCount }) => {
myTicketNumber = ticketNumber;
document.getElementById('issue-my-num').textContent = ticketNumber;
document.getElementById('issue-my-wait').textContent =
waitingCount > 0 ? `앞에 ${waitingCount - 1}명 대기 중` : '곧 호출됩니다!';
document.getElementById('issue-ticket-box').style.display = 'block';
document.getElementById('issue-btn').textContent = '새 번호표 뽑기';
});
socket.on('reset_done', () => {
myTicketNumber = null;
document.getElementById('issue-ticket-box').style.display = 'none';
document.getElementById('issue-btn').disabled = false;
document.getElementById('issue-btn').textContent = '번호표 뽑기';
});
// ── 재연결 시 상태 복구 ──────────────────────────
socket.on('connect', () => {
if (currentPin) socket.emit('join', { pin: currentPin });
});
</script>
</body>
</html>