Files
Poker/public/assets/common.js
T
agomizi1cmandClaude Opus 5.5 d5ef2d310f Add a button to copy a hand's situation as an AI prompt
Reviews and replays get a "copy for AI" button that builds a text
describing the hand (stacks, positions, actions, pots, board, revealed
cards, result and the site's own judgments) plus questions, ready to
paste into an AI chat. Hidden opponent cards are left out so the
evaluation is based only on what Hero could see.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:51:41 +09:00

216 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 各ページ共通のユーティリティ
(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;
},
};
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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';
document.body.appendChild(el);
}
el.textContent = msg;
el.hidden = false;
clearTimeout(toastTimer);
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,
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 backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop confirm-backdrop';
backdrop.innerHTML =
'<div class="modal copy-modal" role="dialog" aria-modal="true">' +
'<h2 class="confirm-title">コピーしてください</h2>' +
'<p class="hint">自動でコピーできませんでした。下の文章を全選択してコピーしてください。</p>' +
'<textarea class="copy-text" readonly></textarea>' +
'<button type="button" class="btn btn-dark copy-close">閉じる</button>' +
'</div>';
const area = backdrop.querySelector('textarea');
area.value = text;
const close = () => backdrop.remove();
backdrop.querySelector('.copy-close').addEventListener('click', close);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close();
});
document.body.appendChild(backdrop);
area.focus();
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, confirm: confirmDialog, api, copyText, connectSocket, safeNext, PHASE_LABELS };
})();