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 =
+ '
' +
+ (o.title ? '
' : '') +
+ '
' +
+ '
' +
+ '' +
+ `` +
+ '
';
+ 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 = '/'; });
});