Replace native confirm() with an in-site confirmation dialog

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>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-20 00:34:43 +09:00
1 parent d89ce1dd30
commit 680aa20d30
5 files changed
+122 -11

No files matched your search

+7
View File
@@ -6,6 +6,13 @@
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"port": 4671
},
{
"name": "poker-test",
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"env": { "ACCESS_KEY": "000000", "ADMIN_KEY": "000001", "PORT": "4672" },
"port": 4672
}
]
}
+72 -1
View File
@@ -52,6 +52,77 @@
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,
@@ -96,5 +167,5 @@
FINISHING: 'FINISHING',
};
window.App = { Store, esc, chips, toast, api, connectSocket, safeNext, PHASE_LABELS };
window.App = { Store, esc, chips, toast, confirm: confirmDialog, api, connectSocket, safeNext, PHASE_LABELS };
})();
+8 -2
View File
@@ -47,12 +47,18 @@
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
});
function deleteTable(id, btn) {
async function deleteTable(id, btn) {
const t = tables && tables.find((x) => x.id === id);
if (!t) return;
const players = t.playerCount ? `
着席中の ${t.playerCount} 人はテーブルから退出させられます。` : '';
if (!confirm(`「${t.name}」を削除しますか?${players}`)) return;
const ok = await App.confirm({
title: 'テーブルを削除',
message: `「${t.name}」を削除しますか?${players}`,
ok: '削除する',
danger: true,
});
if (!ok || !document.contains(btn)) return;
btn.disabled = true;
App.api('DELETE', '/api/tables/' + encodeURIComponent(id))
.then(() => App.toast('テーブルを削除しました'))
+14
View File
@@ -526,6 +526,20 @@ body.table-page {
.modal { background: var(--bg-deep); border-radius: var(--radius); padding: 20px; width: 100%; max-width: 380px; }
.modal h2 { font-size: 18px; margin-bottom: 4px; }
/* 確認ダイアログ(App.confirm)。接続切れの帯やレビューのモーダルより手前に出す */
.confirm-backdrop { z-index: 40; animation: confirm-fade 0.12s ease-out; }
.confirm-modal { max-width: 340px; border: 1px solid var(--line); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); animation: confirm-pop 0.14s ease-out; }
.confirm-title { margin-bottom: 8px; }
.confirm-message { margin: 0; font-size: 15px; line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; }
.confirm-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 20px; }
.confirm-buttons .btn { padding: 12px 8px; font-size: 16px; white-space: normal; }
.confirm-buttons .btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
@keyframes confirm-fade { from { opacity: 0; } }
@keyframes confirm-pop { from { opacity: 0; transform: scale(0.94); } }
@media (prefers-reduced-motion: reduce) {
.confirm-backdrop, .confirm-modal { animation: none; }
}
.toast {
position: fixed;
left: 50%;
+21 -8
View File
@@ -532,7 +532,7 @@
renderAwardPanel(state.hand.pots);
});
$('award-confirm').addEventListener('click', () => {
$('award-confirm').addEventListener('click', async () => {
if (pending || !awardSel.every((set) => set.size > 0)) return;
const winners = awardSel.map((set) => [...set]);
const names = winners.map((w) => w.map(nameOf).join('・'));
@@ -540,7 +540,9 @@
names.length === 1
? `${names[0]} の勝ちで確定しますか?`
: names.map((n, i) => `${i === 0 ? 'メイン' : 'サイド'}: ${n}`).join('\n') + '\nで確定しますか?';
if (!confirm(msg)) return;
const ok = await App.confirm({ title: '勝者の確定', message: msg, ok: '確定する' });
// 確認中に他の人が確定した・ハンドが終わった場合は送らない
if (!ok || pending || $('award-panel').hidden) return;
pending = true;
emit('table:award', { winners });
render();
@@ -589,10 +591,20 @@
const options = () => (state && state.me && state.me.options) || null;
$('act-fold').addEventListener('click', () => {
$('act-fold').addEventListener('click', async () => {
const o = options();
if (!o) return;
if (o.canCheck && !confirm('チェックできます。フォールドしますか?')) return;
if (o.canCheck) {
const ok = await App.confirm({
title: 'フォールドしますか?',
message: 'いまはチップを出さずにチェックできます。',
ok: 'フォールド',
cancel: 'やめる',
danger: true,
});
// 確認中に手番が過ぎていたら何もしない
if (!ok || !options()) return;
}
sendAction('fold');
});
$('act-call').addEventListener('click', () => {
@@ -672,14 +684,15 @@
$('rebuy').addEventListener('click', () => emit('table:rebuy'));
$('take-seat').addEventListener('click', () => join(true));
$('leave').addEventListener('click', () => {
$('leave').addEventListener('click', async () => {
const me = state && state.mySeat >= 0 ? state.seats[state.mySeat] : null;
if (!me) return (location.href = '/');
const inHand = state.hand && !state.hand.finished && me.inHand && !me.folded;
const msg = inHand
? 'ハンドの途中です。退席するとフォールドになり、チップは失われます。退席しますか?'
: 'テーブルから退席しますか?(チップは失われます)';
if (!confirm(msg)) return;
? 'ハンドの途中です。退席するとフォールドになり、チップは失われます。'
: '退席するとチップは失われます。';
const ok = await App.confirm({ title: 'テーブルから退席しますか?', message: msg, ok: '退席する', danger: true });
if (!ok) return;
socket.emit('table:leave', null, () => { location.href = '/'; });
});