- Per-device toggle to hide your own net and the net ranking at tables - Default starting stack set in settings; creating a table saves the stack, SB and BB used as the next defaults - Number fields on create and settings select all on focus Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
12 KiB
JavaScript
276 lines
12 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;
|
||
},
|
||
// テーブルで自分の収支を表示するか(設定画面で切り替える。既定は表示)
|
||
get showNet() { return lsGet('poker.showNet') !== '0'; },
|
||
set showNet(v) { lsSet('poker.showNet', v ? '1' : '0'); },
|
||
// テーブル作成画面の開始スタックの初期値(設定画面で変えられ、テーブルを作るたびに前回値で上書きする)
|
||
get defaultStack() {
|
||
const n = Number(lsGet('poker.defaultStack'));
|
||
return Number.isInteger(n) && n >= 10 && n <= 100000000 ? n : 1000;
|
||
},
|
||
set defaultStack(v) { lsSet('poker.defaultStack', String(v)); },
|
||
// 前回テーブルを作ったときの SB/BB と、そのときの開始スタック { stack, sb, bb }
|
||
get lastBlinds() {
|
||
try {
|
||
const b = JSON.parse(lsGet('poker.lastBlinds'));
|
||
return b && [b.stack, b.sb, b.bb].every(Number.isInteger) && b.sb >= 1 && b.bb > b.sb && b.bb <= b.stack ? b : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
set lastBlinds(v) { lsSet('poker.lastBlinds', JSON.stringify(v)); },
|
||
};
|
||
|
||
// 数値の入力欄: フォーカスしたら全体を選択して、そのまま打ち替えられるようにする
|
||
function selectOnFocus(el) {
|
||
let skipMouseUp = false;
|
||
el.addEventListener('focus', () => {
|
||
el.select();
|
||
skipMouseUp = true;
|
||
// iOS Safari はタップの処理の後で選択が外れるので、もう一度選ぶ
|
||
setTimeout(() => {
|
||
if (document.activeElement === el) el.select();
|
||
}, 0);
|
||
});
|
||
// クリックで入ったときは、離した位置にカーソルが置かれて選択が外れるのを防ぐ
|
||
el.addEventListener('mouseup', (e) => {
|
||
if (skipMouseUp) e.preventDefault();
|
||
skipMouseUp = false;
|
||
});
|
||
el.addEventListener('keydown', () => { skipMouseUp = false; });
|
||
}
|
||
|
||
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';
|
||
el.setAttribute('role', 'status');
|
||
el.setAttribute('aria-live', 'polite');
|
||
document.body.appendChild(el);
|
||
void el.offsetWidth; // 最初の1回も画面の外から動かす
|
||
}
|
||
el.textContent = msg;
|
||
el.classList.add('show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => el.classList.remove('show'), 2600);
|
||
}
|
||
|
||
// ブラウザ標準の confirm() の代わりの確認ダイアログ(iOS のアラートの形)。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 = 'alert-backdrop';
|
||
backdrop.innerHTML =
|
||
'<div class="alert" role="alertdialog" aria-modal="true">' +
|
||
'<div class="alert-body">' +
|
||
(o.title ? '<h2 class="alert-title"></h2>' : '') +
|
||
'<p class="alert-message"></p>' +
|
||
'</div>' +
|
||
'<div class="alert-buttons">' +
|
||
'<button type="button" class="alert-btn" data-confirm="cancel"></button>' +
|
||
`<button type="button" class="alert-btn ${o.danger ? 'destructive' : 'default'}" data-confirm="ok"></button>` +
|
||
'</div></div>';
|
||
const dialog = backdrop.firstChild;
|
||
const msgEl = dialog.querySelector('.alert-message');
|
||
msgEl.textContent = o.message || '';
|
||
msgEl.id = 'confirm-message-' + randomHex(4);
|
||
dialog.setAttribute('aria-describedby', msgEl.id);
|
||
if (o.title) {
|
||
const titleEl = dialog.querySelector('.alert-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 || 'キャンセル';
|
||
|
||
// 現れるときは少し大きい所から縮み、消えるときはその場で薄くなる
|
||
let closing = false;
|
||
const appear = new Fluid.Spring(0, { precision: 0.002 });
|
||
appear.onUpdate = (v) => {
|
||
backdrop.style.setProperty('--appear', v.toFixed(3));
|
||
dialog.style.opacity = v >= 1 ? '' : v.toFixed(3);
|
||
dialog.style.transform = closing || Fluid.reducedMotion() ? '' : `scale(${(1.12 - 0.12 * v).toFixed(4)})`;
|
||
};
|
||
|
||
const prevFocus = document.activeElement;
|
||
return new Promise((resolve) => {
|
||
function close(result) {
|
||
if (closeConfirm !== close) return;
|
||
closeConfirm = null;
|
||
closing = true;
|
||
document.removeEventListener('keydown', onKey, true);
|
||
backdrop.style.pointerEvents = 'none';
|
||
appear.to(0, { damping: 1, response: 0.2, allowReduced: true, onRest: () => 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));
|
||
// 背景を押したらキャンセル(ダイアログ内から背景へのドラッグは除く)
|
||
let downOnBackdrop = false;
|
||
backdrop.addEventListener('pointerdown', (e) => {
|
||
downOnBackdrop = e.target === backdrop;
|
||
});
|
||
backdrop.addEventListener('click', (e) => {
|
||
if (e.target === backdrop && downOnBackdrop) close(false);
|
||
});
|
||
document.addEventListener('keydown', onKey, true);
|
||
closeConfirm = close;
|
||
document.body.appendChild(backdrop);
|
||
appear.set(0);
|
||
appear.to(1, { damping: 1, response: 0.3, allowReduced: true });
|
||
// 危険な操作はうっかり 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;
|
||
}
|
||
|
||
// 文章をクリップボードにコピーする。load は文章を返す Promise を作る関数。
|
||
// サーバから取ってくる間にボタンを押した扱いが切れる(iOS Safari など)ので、まず ClipboardItem に
|
||
// Promise のまま渡し、だめなら writeText、それもだめなら文章を選択した状態で表示して手でコピーしてもらう
|
||
async function copyText(load) {
|
||
const pending = load();
|
||
try {
|
||
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
|
||
const blob = pending.then((t) => new Blob([t], { type: 'text/plain' }));
|
||
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]);
|
||
return true;
|
||
}
|
||
} catch { /* 次の方法へ */ }
|
||
const text = await pending; // 文章を取れなかったときはここで呼び出し元へエラーを返す
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
} catch { /* 手でコピーしてもらう */ }
|
||
showCopyDialog(text);
|
||
return false;
|
||
}
|
||
|
||
function showCopyDialog(text) {
|
||
const root = document.createElement('div');
|
||
root.className = 'sheet-backdrop';
|
||
root.hidden = true;
|
||
root.innerHTML =
|
||
'<div class="sheet" role="dialog" aria-modal="true" aria-label="コピーしてください" tabindex="-1">' +
|
||
'<div class="sheet-header" data-sheet-drag><span class="sheet-grabber" aria-hidden="true"></span>' +
|
||
'<h2 class="sheet-title">コピーしてください</h2></div>' +
|
||
'<div class="sheet-scroll">' +
|
||
'<p class="hint">自動でコピーできませんでした。下の文章を全選択してコピーしてください。</p>' +
|
||
'<textarea class="copy-text" readonly></textarea>' +
|
||
'</div>' +
|
||
'<div class="sheet-footer"><button type="button" class="btn btn-lg btn-block copy-close">閉じる</button></div>' +
|
||
'</div>';
|
||
const area = root.querySelector('textarea');
|
||
area.value = text;
|
||
document.body.appendChild(root);
|
||
const sheet = Fluid.Sheet(root, { onClosed: () => root.remove() });
|
||
root.querySelector('.copy-close').addEventListener('click', () => sheet.close());
|
||
sheet.open();
|
||
area.focus({ preventScroll: true });
|
||
area.select();
|
||
area.scrollTop = 0; // select() で末尾までスクロールするので先頭に戻す
|
||
}
|
||
|
||
// サーバは 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, selectOnFocus, confirm: confirmDialog, api, copyText, connectSocket, safeNext, PHASE_LABELS };
|
||
})();
|