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:
1 parent
d89ce1dd30
commit
680aa20d30
5 files changed
+122
-11
No files matched your search
@@ -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
@@ -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 };
|
||||
})();
|
||||
@@ -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('テーブルを削除しました'))
|
||||
|
||||
@@ -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
@@ -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 = '/'; });
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user