Add App.confirm() in common.js: a promise-based dialog styled like the site's modals, with a title, multi-line message and custom button labels. Destructive actions use a red OK button and focus Cancel by default; Escape and backdrop clicks cancel, and Tab stays inside the dialog. Use it for folding when a check is possible, leaving the table, confirming showdown winners and deleting tables from the lobby. After the dialog closes, re-check that the action is still valid (turn not passed, pot not already awarded, table still listed) before sending it. Also add a "poker-test" launch config that runs on port 4672 with the example keys from .env.example, for local browser testing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
6.4 KiB
JavaScript
172 lines
6.4 KiB
JavaScript
// 各ページ共通のユーティリティ
|
||
(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 =
|
||
'<div class="modal confirm-modal" role="alertdialog" aria-modal="true">' +
|
||
(o.title ? '<h2 class="confirm-title"></h2>' : '') +
|
||
'<p class="confirm-message"></p>' +
|
||
'<div class="confirm-buttons">' +
|
||
'<button type="button" class="btn btn-dark" data-confirm="cancel"></button>' +
|
||
`<button type="button" class="btn ${o.danger ? 'btn-red' : 'btn-orange'}" data-confirm="ok"></button>` +
|
||
'</div></div>';
|
||
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 };
|
||
})();
|