From 680aa20d3099c8f3089b4fcf4a7283f5dc174412 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Tue, 20 Oct 2026 00:34:43 +0900 Subject: [PATCH] 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 --- .claude/launch.json | 7 ++++ public/assets/common.js | 73 ++++++++++++++++++++++++++++++++++++++++- public/assets/lobby.js | 10 ++++-- public/assets/style.css | 14 ++++++++ public/assets/table.js | 29 +++++++++++----- 5 files changed, 122 insertions(+), 11 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index 3e641c6..94863e2 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -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 } ] } diff --git a/public/assets/common.js b/public/assets/common.js index e57c8e8..7aa16d2 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -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 = + ''; + 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 }; })(); diff --git a/public/assets/lobby.js b/public/assets/lobby.js index 517cc52..a12a59d 100644 --- a/public/assets/lobby.js +++ b/public/assets/lobby.js @@ -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('テーブルを削除しました')) diff --git a/public/assets/style.css b/public/assets/style.css index 6db5a9d..dc35657 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -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%; diff --git a/public/assets/table.js b/public/assets/table.js index 38bd492..5d9f6b7 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -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 = '/'; }); });