Files
Poker/public/assets/common.js
T
agomizi1cmandClaude Opus 5.5 c3fbfdd12e Rebuild the UI in an Apple style with spring-driven motion
Replace the look of every page (key, lobby, create, table, range, review)
with light/dark semantic colors, translucent materials, iOS-style grouped
lists and system typography. Add fluid.js, a small spring engine with
interruptible, velocity-aware animations, and use it for draggable bottom
sheets, sliding segmented controls, a custom bet slider with rubber-banding,
swipe-to-delete in the lobby, and dealt/flipped cards. Seats and cards are
now reused between renders so animations are not cut off. Honors reduced
motion, reduced transparency and increased contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:44:21 +09:00

238 lines
9.7 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';
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, confirm: confirmDialog, api, copyText, connectSocket, safeNext, PHASE_LABELS };
})();