// 各ページ共通のユーティリティ (function () { 'use strict'; function lsGet(key) { try { return localStorage.getItem(key); } catch { return null; } } function lsSet(key, value) { try { localStorage.setItem(key, value); } catch { /* プライベートモード等 */ } } // 非HTTPS環境でも動くよう crypto.randomUUID ではなく getRandomValues を使う function randomHex(bytes) { const a = new Uint8Array(bytes); crypto.getRandomValues(a); return Array.from(a, (b) => b.toString(16).padStart(2, '0')).join(''); } const Store = { get name() { return lsGet('poker.name') || ''; }, set name(v) { lsSet('poker.name', v); }, // 席の再接続用。ブラウザごとに固定 get token() { let t = lsGet('poker.token'); if (!t || !/^[0-9a-f]{32}$/.test(t)) { t = randomHex(16); lsSet('poker.token', t); } return t; }, }; function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function chips(n) { return '$' + Number(n).toLocaleString('en-US'); } let toastTimer; function toast(msg) { let el = document.querySelector('.toast'); if (!el) { el = document.createElement('div'); el.className = 'toast'; document.body.appendChild(el); } el.textContent = msg; el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 2500); } // ブラウザ標準の confirm() の代わりの確認ダイアログ。OK なら true で解決する。 // opts: { title, message(改行可), ok, cancel, danger(OK を赤にする) } // 新しいダイアログを開くと、開いていたものはキャンセル扱いで閉じる let closeConfirm = null; function confirmDialog(opts) { const o = typeof opts === 'string' ? { message: opts } : opts; if (closeConfirm) closeConfirm(false); const backdrop = document.createElement('div'); backdrop.className = 'modal-backdrop confirm-backdrop'; backdrop.innerHTML = ''; const dialog = backdrop.firstChild; const msgEl = dialog.querySelector('.confirm-message'); msgEl.textContent = o.message || ''; msgEl.id = 'confirm-message-' + randomHex(4); dialog.setAttribute('aria-describedby', msgEl.id); if (o.title) { const titleEl = dialog.querySelector('.confirm-title'); titleEl.textContent = o.title; titleEl.id = 'confirm-title-' + randomHex(4); dialog.setAttribute('aria-labelledby', titleEl.id); } const okBtn = dialog.querySelector('[data-confirm="ok"]'); const cancelBtn = dialog.querySelector('[data-confirm="cancel"]'); okBtn.textContent = o.ok || 'OK'; cancelBtn.textContent = o.cancel || 'キャンセル'; const prevFocus = document.activeElement; return new Promise((resolve) => { function close(result) { if (closeConfirm !== close) return; closeConfirm = null; document.removeEventListener('keydown', onKey, true); backdrop.remove(); if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus(); resolve(result); } function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(false); } else if (e.key === 'Tab') { // フォーカスをダイアログの2つのボタンの中に閉じ込める e.preventDefault(); (document.activeElement === okBtn ? cancelBtn : okBtn).focus(); } } okBtn.addEventListener('click', () => close(true)); cancelBtn.addEventListener('click', () => close(false)); // 背景を押したらキャンセル(ダイアログ内から背景へのドラッグは除く) backdrop.addEventListener('pointerdown', (e) => { backdrop.dataset.downOnBackdrop = e.target === backdrop ? '1' : ''; }); backdrop.addEventListener('click', (e) => { if (e.target === backdrop && backdrop.dataset.downOnBackdrop) close(false); }); document.addEventListener('keydown', onKey, true); closeConfirm = close; document.body.appendChild(backdrop); // 危険な操作はうっかり Enter で確定しないようキャンセルにフォーカスする (o.danger ? cancelBtn : okBtn).focus(); }); } async function api(method, url, body) { const res = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin', }); if (res.status === 401 && url !== '/api/auth') { location.href = '/key?next=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); } const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'エラーが発生しました'); return data; } // サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する function connectSocket() { const socket = io({ transports: ['websocket'] }); socket.on('connect_error', () => { fetch('/api/tables', { credentials: 'same-origin' }) .then((res) => { if (res.status === 401) location.href = '/key?next=' + encodeURIComponent(location.pathname); }) .catch(() => {}); }); return socket; } // 自サイト内のパスだけ許可("//x" や "/\x" は外部サイト扱いになるので拒否) function safeNext(next) { return typeof next === 'string' && /^\/(?![/\\])[^\\\s]*$/.test(next) && next.length <= 200 ? next : '/'; } const PHASE_LABELS = { WAITING: 'WAITING', PREFLOP: 'PREFLOP', FLOP: 'FLOP', TURN: 'TURN', RIVER: 'RIVER', SHOWDOWN: 'SHOWDOWN', FINISHING: 'FINISHING', }; window.App = { Store, esc, chips, toast, confirm: confirmDialog, api, connectSocket, safeNext, PHASE_LABELS }; })();