Initial commit: private Texas Hold'em ring game site (lobby + table stub)
- 6-digit access key gate with long-lived HttpOnly cookie, brute-force limits - Lobby with saved player name and live table list - Table creation (name, starting stack, blinds, blind timer) - Table play screen stub (seats, button/SB/BB, phase, blinds, timer) - Security hardening: strict CSP, origin checks, host pinning, input sanitizing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b2c91062f1
22 files changed
+3450
No files matched your search
@@ -0,0 +1,57 @@
|
||||
// ブラインド計算(サーバ・ブラウザ共用)
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.Blinds = factory();
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
// 1, 2, 3, 4, 5, 6, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 150, ...
|
||||
const MANTISSAS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5];
|
||||
const LADDER = [];
|
||||
for (let e = 0; e <= 9; e++) {
|
||||
for (const m of MANTISSAS) {
|
||||
const v = Math.round(m * Math.pow(10, e) * 100) / 100;
|
||||
if (Number.isInteger(v)) LADDER.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
function nearestNice(x) {
|
||||
if (!(x >= 1)) return 1;
|
||||
let best = LADDER[0];
|
||||
for (const v of LADDER) if (Math.abs(v - x) < Math.abs(best - x)) best = v;
|
||||
return best;
|
||||
}
|
||||
|
||||
// 開始スタックに合うブラインド(BB = スタックの約1/100)
|
||||
function defaultBlinds(stack) {
|
||||
const sb = nearestNice(stack / 200);
|
||||
return { sb, bb: sb * 2 };
|
||||
}
|
||||
|
||||
// 1レベルごとに約1.5倍
|
||||
function nextSmallBlind(sb) {
|
||||
const target = sb * 1.4;
|
||||
for (const v of LADDER) if (v >= target && v > sb) return v;
|
||||
return Math.ceil(sb * 1.5);
|
||||
}
|
||||
|
||||
// level は 1 始まり
|
||||
function blindsAt(sb0, bb0, level) {
|
||||
const ratio = bb0 / sb0;
|
||||
let sb = sb0;
|
||||
let bb = bb0;
|
||||
for (let i = 2; i <= level; i++) {
|
||||
sb = nextSmallBlind(sb);
|
||||
bb = Math.round(sb * ratio);
|
||||
}
|
||||
return { sb, bb };
|
||||
}
|
||||
|
||||
function schedule(sb0, bb0, levels) {
|
||||
const out = [];
|
||||
for (let i = 1; i <= levels; i++) out.push(Object.assign({ level: i }, blindsAt(sb0, bb0, i)));
|
||||
return out;
|
||||
}
|
||||
|
||||
return { defaultBlinds, blindsAt, schedule };
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// 各ページ共通のユーティリティ
|
||||
(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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// サーバは 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',
|
||||
};
|
||||
|
||||
window.App = { Store, esc, chips, toast, api, connectSocket, safeNext, PHASE_LABELS };
|
||||
})();
|
||||
@@ -0,0 +1,143 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { Store, api, chips } = App;
|
||||
if (!Store.name) {
|
||||
location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const form = $('form');
|
||||
const nameInput = $('table-name');
|
||||
const stackInput = $('stack');
|
||||
const sbInput = $('sb');
|
||||
const bbInput = $('bb');
|
||||
const timerInput = $('timer');
|
||||
const intervalInput = $('interval');
|
||||
const scheduleEl = $('schedule');
|
||||
const blindHint = $('blind-hint');
|
||||
const errorEl = $('error');
|
||||
const submitBtn = $('submit');
|
||||
|
||||
// ブラインドを手で変えるまではスタックに追従させる
|
||||
let blindsTouched = false;
|
||||
|
||||
async function suggestName() {
|
||||
try {
|
||||
const { name } = await api('GET', '/api/tables/suggest-name');
|
||||
nameInput.value = name;
|
||||
} catch { /* 失敗時は手入力 */ }
|
||||
}
|
||||
|
||||
const int = (el) => {
|
||||
const n = Number(el.value);
|
||||
return Number.isInteger(n) ? n : NaN;
|
||||
};
|
||||
|
||||
function applyDefaultBlinds() {
|
||||
const stack = int(stackInput);
|
||||
if (!(stack >= 10)) return;
|
||||
const { sb, bb } = Blinds.defaultBlinds(stack);
|
||||
sbInput.value = sb;
|
||||
bbInput.value = bb;
|
||||
}
|
||||
|
||||
function updateHint() {
|
||||
const stack = int(stackInput);
|
||||
const bb = int(bbInput);
|
||||
if (blindsTouched && stack > 0 && bb > 0) {
|
||||
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link-gold">おすすめに戻す</a>`;
|
||||
} else {
|
||||
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
|
||||
}
|
||||
}
|
||||
|
||||
function updateSchedule() {
|
||||
const sb = int(sbInput);
|
||||
const bb = int(bbInput);
|
||||
const on = timerInput.checked;
|
||||
intervalInput.disabled = !on;
|
||||
scheduleEl.hidden = !on || !(sb > 0 && bb >= sb);
|
||||
if (scheduleEl.hidden) return;
|
||||
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
|
||||
scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8)
|
||||
.map((l, i) => `<span>Lv.${l.level} <b>${chips(l.sb)}/${chips(l.bb)}</b>${mins ? ` · ${i * mins}分〜` : ''}</span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
updateHint();
|
||||
updateSchedule();
|
||||
}
|
||||
|
||||
stackInput.addEventListener('input', () => {
|
||||
if (!blindsTouched) applyDefaultBlinds();
|
||||
refresh();
|
||||
});
|
||||
[sbInput, bbInput].forEach((el) =>
|
||||
el.addEventListener('input', () => {
|
||||
blindsTouched = true;
|
||||
if (el === sbInput && int(sbInput) > 0) bbInput.value = int(sbInput) * 2;
|
||||
refresh();
|
||||
})
|
||||
);
|
||||
blindHint.addEventListener('click', (e) => {
|
||||
if (e.target.id !== 'reset-blinds') return;
|
||||
e.preventDefault();
|
||||
blindsTouched = false;
|
||||
applyDefaultBlinds();
|
||||
refresh();
|
||||
});
|
||||
timerInput.addEventListener('change', refresh);
|
||||
intervalInput.addEventListener('input', refresh);
|
||||
$('reroll').addEventListener('click', suggestName);
|
||||
|
||||
function validate() {
|
||||
const name = nameInput.value.trim();
|
||||
const stack = int(stackInput);
|
||||
const sb = int(sbInput);
|
||||
const bb = int(bbInput);
|
||||
const interval = int(intervalInput);
|
||||
if (!name) return [nameInput, 'テーブル名を入力してください'];
|
||||
if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください'];
|
||||
if (!(sb >= 1)) return [sbInput, 'SBは1以上の整数にしてください'];
|
||||
if (!(bb >= sb)) return [bbInput, 'BBはSB以上にしてください'];
|
||||
if (bb > stack) return [bbInput, 'BBが開始スタックより大きいです'];
|
||||
if (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください'];
|
||||
return null;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
form.querySelectorAll('.invalid').forEach((el) => el.classList.remove('invalid'));
|
||||
const problem = validate();
|
||||
if (problem) {
|
||||
problem[0].classList.add('invalid');
|
||||
problem[0].focus();
|
||||
errorEl.textContent = problem[1];
|
||||
return;
|
||||
}
|
||||
errorEl.textContent = '';
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const { id } = await api('POST', '/api/tables', {
|
||||
name: nameInput.value.trim(),
|
||||
startingStack: int(stackInput),
|
||||
smallBlind: int(sbInput),
|
||||
bigBlind: int(bbInput),
|
||||
blindTimerEnabled: timerInput.checked,
|
||||
blindIntervalMin: int(intervalInput),
|
||||
token: Store.token,
|
||||
});
|
||||
location.href = '/table/' + encodeURIComponent(id);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
applyDefaultBlinds();
|
||||
refresh();
|
||||
suggestName();
|
||||
})();
|
||||
@@ -0,0 +1,82 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const form = document.getElementById('form');
|
||||
const box = document.getElementById('digits');
|
||||
const inputs = [...box.querySelectorAll('input')];
|
||||
const errorEl = document.getElementById('error');
|
||||
const submitBtn = document.getElementById('submit');
|
||||
let busy = false;
|
||||
|
||||
const value = () => inputs.map((i) => i.value).join('');
|
||||
|
||||
function fill(digits, from) {
|
||||
let i = from;
|
||||
for (const d of digits) {
|
||||
if (i >= inputs.length) break;
|
||||
inputs[i++].value = d;
|
||||
}
|
||||
inputs[Math.min(i, inputs.length - 1)].focus();
|
||||
if (value().length === 6) submit();
|
||||
}
|
||||
|
||||
inputs.forEach((input, idx) => {
|
||||
input.addEventListener('input', () => {
|
||||
const digits = input.value.replace(/\D/g, '');
|
||||
input.value = '';
|
||||
if (digits) fill(digits, idx);
|
||||
errorEl.textContent = '';
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' && !input.value && idx > 0) {
|
||||
inputs[idx - 1].value = '';
|
||||
inputs[idx - 1].focus();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowLeft' && idx > 0) {
|
||||
inputs[idx - 1].focus();
|
||||
} else if (e.key === 'ArrowRight' && idx < inputs.length - 1) {
|
||||
inputs[idx + 1].focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('paste', (e) => {
|
||||
const digits = (e.clipboardData.getData('text') || '').replace(/\D/g, '');
|
||||
if (!digits) return;
|
||||
e.preventDefault();
|
||||
fill(digits, idx);
|
||||
});
|
||||
input.addEventListener('focus', () => input.select());
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (busy) return;
|
||||
const key = value();
|
||||
if (key.length !== 6) {
|
||||
errorEl.textContent = '6桁すべて入力してください。';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
await App.api('POST', '/api/auth', { key });
|
||||
const next = new URLSearchParams(location.search).get('next');
|
||||
location.replace(App.safeNext(next));
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
box.classList.remove('shake');
|
||||
void box.offsetWidth;
|
||||
box.classList.add('shake');
|
||||
inputs.forEach((i) => { i.value = ''; });
|
||||
inputs[0].focus();
|
||||
} finally {
|
||||
busy = false;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
});
|
||||
|
||||
inputs[0].focus();
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { Store, esc, chips } = App;
|
||||
const nameInput = document.getElementById('name');
|
||||
const nameError = document.getElementById('name-error');
|
||||
const listEl = document.getElementById('list');
|
||||
const countEl = document.getElementById('count');
|
||||
|
||||
nameInput.value = Store.name;
|
||||
nameInput.addEventListener('input', () => {
|
||||
Store.name = nameInput.value.trim();
|
||||
nameInput.classList.remove('invalid');
|
||||
nameError.textContent = '';
|
||||
});
|
||||
|
||||
function requireName() {
|
||||
if (Store.name) return true;
|
||||
nameInput.classList.add('invalid');
|
||||
nameError.textContent = '先に名前を入力してください。';
|
||||
nameInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
document.getElementById('create').addEventListener('click', () => {
|
||||
if (requireName()) location.href = '/create';
|
||||
});
|
||||
|
||||
listEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-join]');
|
||||
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
|
||||
});
|
||||
|
||||
function phaseBadge(t) {
|
||||
if (t.paused) return '<span class="badge paused">PAUSED</span>';
|
||||
if (t.phase === 'WAITING') return '<span class="badge">WAITING</span>';
|
||||
return '<span class="badge live">PLAYING</span>';
|
||||
}
|
||||
|
||||
function render(tables) {
|
||||
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
|
||||
if (!tables.length) {
|
||||
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = tables
|
||||
.map((t) => {
|
||||
const full = t.playerCount >= t.maxSeats;
|
||||
const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし';
|
||||
return `
|
||||
<div class="table-item">
|
||||
<div class="info">
|
||||
<div class="name">${esc(t.name)}${phaseBadge(t)}</div>
|
||||
<div class="meta">
|
||||
<span>👤 <b>${t.playerCount}/${t.maxSeats}</b></span>
|
||||
<span>開始 <b>${chips(t.startingStack)}</b></span>
|
||||
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
|
||||
<span>${timer}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
|
||||
${full ? '観戦' : '参加'}
|
||||
</button>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
const socket = App.connectSocket();
|
||||
socket.on('connect', () => socket.emit('lobby:subscribe'));
|
||||
socket.on('lobby:tables', render);
|
||||
})();
|
||||
@@ -0,0 +1,514 @@
|
||||
:root {
|
||||
--bg: #24493b;
|
||||
--bg-deep: #1b3a2e;
|
||||
--panel: #1a2620;
|
||||
--panel-2: #26332c;
|
||||
--input: #303a35;
|
||||
--line: #3a4a42;
|
||||
--text: #f2f5f3;
|
||||
--muted: #9fb2a8;
|
||||
--felt: #2ecc71;
|
||||
--felt-dark: #23a85b;
|
||||
--gold: #f6c744;
|
||||
--orange: #f0a43a;
|
||||
--blue: #4a90e2;
|
||||
--green: #2ecc71;
|
||||
--red: #ff5a5a;
|
||||
--radius: 14px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body { min-height: 100dvh; }
|
||||
|
||||
.wrap {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 40px;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin: 0; }
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 48px;
|
||||
margin: 28px 0 32px;
|
||||
}
|
||||
.logo small {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--gold);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
margin: 28px 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.section-title .sub { font-size: 12px; font-weight: 600; color: var(--muted); }
|
||||
|
||||
label, .label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="number"], input[type="tel"] {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
background: var(--input);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus { border-color: var(--felt); }
|
||||
input:disabled { opacity: 0.4; }
|
||||
input.invalid { border-color: var(--red); }
|
||||
|
||||
.row { display: flex; gap: 10px; align-items: center; }
|
||||
.row > * { flex: 1; min-width: 0; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: transform 0.06s, filter 0.15s, opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: scale(0.97); }
|
||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn-orange { background: var(--orange); }
|
||||
.btn-blue { background: var(--blue); }
|
||||
.btn-green { background: var(--green); }
|
||||
.btn-dark { background: var(--panel-2); color: var(--text); }
|
||||
.btn-ghost { background: transparent; color: var(--muted); border: 2px solid var(--line); }
|
||||
.btn-block { width: 100%; }
|
||||
.btn-sm { font-size: 14px; padding: 9px 14px; border-radius: 8px; }
|
||||
|
||||
.icon-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: var(--text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.divider { height: 1px; background: var(--line); margin: 24px 0; }
|
||||
|
||||
.error-text { color: var(--red); font-size: 14px; min-height: 20px; margin-top: 10px; }
|
||||
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ---------- 鍵入力 ---------- */
|
||||
.key-inputs { display: flex; gap: 6px; justify-content: center; margin: 8px 0 4px; }
|
||||
.key-inputs input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 52px;
|
||||
height: 60px;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.shake { animation: shake 0.35s; }
|
||||
@keyframes shake {
|
||||
20%, 60% { transform: translateX(-8px); }
|
||||
40%, 80% { transform: translateX(8px); }
|
||||
}
|
||||
|
||||
/* ---------- ロビー ---------- */
|
||||
.table-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.table-item {
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 14px 14px 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.table-item .info { flex: 1; min-width: 0; }
|
||||
.table-item .name {
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-item .meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.table-item .meta b { color: var(--text); font-weight: 700; }
|
||||
.table-item .meta .gold { color: var(--gold); }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
vertical-align: 2px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.badge.live { background: rgba(46, 204, 113, 0.18); color: var(--green); }
|
||||
.badge.paused { background: rgba(255, 90, 90, 0.15); color: var(--red); }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 32px 16px;
|
||||
border: 2px dashed var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---------- テーブル作成 ---------- */
|
||||
.topbar { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.topbar h1 { font-size: 20px; flex: 1; }
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 22px;
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
}
|
||||
.switch { position: relative; width: 52px; height: 30px; flex: none; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.switch span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--input);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.switch span::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: #ddd;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.switch input:checked + span { background: var(--green); }
|
||||
.switch input:checked + span::before { transform: translateX(22px); background: #fff; }
|
||||
.switch input:focus-visible + span { outline: 2px solid var(--gold); }
|
||||
|
||||
.unit-input { position: relative; }
|
||||
.unit-input input { padding-right: 56px; }
|
||||
.unit-input .unit {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.schedule span {
|
||||
font-size: 12px;
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.schedule span b { color: var(--gold); font-weight: 700; }
|
||||
|
||||
/* ---------- プレイ画面 ---------- */
|
||||
.table-page { display: flex; flex-direction: column; min-height: 100dvh; }
|
||||
.table-page .wrap { padding-top: 16px; padding-bottom: 0; flex: 1; display: flex; flex-direction: column; width: 100%; }
|
||||
|
||||
.table-header { display: flex; align-items: center; gap: 12px; }
|
||||
.table-header h1 {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.icon-btn.leave { color: var(--red); border-color: rgba(255, 90, 90, 0.35); }
|
||||
|
||||
.host-controls { display: flex; justify-content: center; gap: 8px; margin: 16px 0 12px; flex-wrap: wrap; }
|
||||
.host-controls .btn { border-radius: 999px; padding: 10px 16px; font-size: 14px; }
|
||||
|
||||
.level-bar {
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.level-bar .lv { color: var(--gold); font-weight: 800; }
|
||||
.level-bar .blinds { font-weight: 800; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.level-bar .timer { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 18px; }
|
||||
.level-bar .timer.warn { color: var(--orange); }
|
||||
.level-bar .timer.paused { color: var(--red); }
|
||||
.level-sub { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin: 6px 4px 0; }
|
||||
|
||||
.felt-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.felt {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 62%;
|
||||
height: 72%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-dark) 100%);
|
||||
box-shadow: 0 0 0 14px rgba(46, 204, 113, 0.18), inset 0 0 40px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pot-label { font-weight: 900; color: rgba(0, 0, 0, 0.3); letter-spacing: 0.1em; font-size: 14px; }
|
||||
.pot {
|
||||
font-size: 40px;
|
||||
font-weight: 900;
|
||||
color: var(--gold);
|
||||
text-shadow: 0 3px 0 rgba(0, 0, 0, 0.3);
|
||||
line-height: 1;
|
||||
}
|
||||
.phase {
|
||||
margin-top: 8px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 13px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.hand-no { font-size: 11px; color: rgba(0, 0, 0, 0.45); font-weight: 700; margin-top: 2px; }
|
||||
|
||||
.seat {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 92px;
|
||||
transition: left 0.3s, top 0.3s;
|
||||
}
|
||||
.avatar {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
background: #3b4a5c;
|
||||
border: 3px solid #8a96a3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.avatar svg { width: 28px; height: 28px; fill: #fff; }
|
||||
.seat.me .avatar { background: #d35400; border-color: var(--gold); }
|
||||
.seat.offline .avatar { opacity: 0.45; }
|
||||
.nameplate {
|
||||
margin-top: -10px;
|
||||
background: rgba(10, 30, 20, 0.88);
|
||||
border-radius: 10px;
|
||||
padding: 4px 8px;
|
||||
min-width: 84px;
|
||||
max-width: 92px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.nameplate .pname {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nameplate .pstack { font-size: 13px; font-weight: 800; color: var(--gold); }
|
||||
.chip-marker {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #222;
|
||||
}
|
||||
.chip-marker.dealer { background: #fff; color: #111; top: -4px; right: -8px; }
|
||||
.blind-marker {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -10px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
padding: 2px 5px;
|
||||
border-radius: 6px;
|
||||
color: #111;
|
||||
}
|
||||
.blind-marker.sb { background: #7fd3ff; }
|
||||
.blind-marker.bb { background: var(--gold); }
|
||||
.host-mark { position: absolute; bottom: -2px; right: -6px; font-size: 14px; }
|
||||
.offline-tag { font-size: 10px; color: var(--muted); }
|
||||
|
||||
.spectator-note {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
background: #161c19;
|
||||
padding: 12px 16px calc(14px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.action-bar .inner { max-width: 480px; margin: 0 auto; }
|
||||
.my-stack {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.my-stack .wallet { color: var(--gold); font-size: 22px; }
|
||||
.actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.actions .btn { background: #555; color: #bbb; font-size: 18px; }
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
.modal { background: var(--bg-deep); border-radius: var(--radius); padding: 20px; width: 100%; max-width: 380px; }
|
||||
.modal h2 { font-size: 18px; margin-bottom: 4px; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 160px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
max-width: calc(100% - 32px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.center-message { text-align: center; padding: 60px 16px; color: var(--muted); }
|
||||
.center-message h2 { color: var(--text); margin-bottom: 12px; }
|
||||
|
||||
.conn-lost {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--red);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 6px;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ---------- ユーティリティ(CSP で style 属性を使わないため) ---------- */
|
||||
.text-center { text-align: center; }
|
||||
.mt-4 { margin-top: 4px; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-12 { margin-top: 12px; }
|
||||
.key-title { font-size: 18px; text-align: center; }
|
||||
.key-hint { text-align: center; margin: 6px 0 16px; }
|
||||
.slash { flex: none !important; color: var(--muted); font-weight: 800; }
|
||||
label.switch { margin: 0; display: inline-block; }
|
||||
.link-gold { color: var(--gold); }
|
||||
.host-spacer { height: 12px; }
|
||||
@@ -0,0 +1,187 @@
|
||||
// プレイ画面(スタブ): サーバから受け取ったテーブル状態を表示するだけ
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { Store, esc, chips, toast } = App;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const tableId = decodeURIComponent(location.pathname.split('/').pop());
|
||||
|
||||
const AVATAR_SVG =
|
||||
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4.5"/><path d="M3 21c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5z"/></svg>';
|
||||
|
||||
let socket = null;
|
||||
let state = null;
|
||||
let clockOffset = 0; // serverNow - clientNow
|
||||
|
||||
// ---------- 名前 ----------
|
||||
function ensureName() {
|
||||
if (Store.name) return connect();
|
||||
$('name-modal').hidden = false;
|
||||
$('name-input').focus();
|
||||
}
|
||||
$('name-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const name = $('name-input').value.trim();
|
||||
if (!name) return $('name-input').classList.add('invalid');
|
||||
Store.name = name;
|
||||
$('name-modal').hidden = true;
|
||||
connect();
|
||||
});
|
||||
|
||||
// ---------- 通信 ----------
|
||||
function connect() {
|
||||
socket = App.connectSocket();
|
||||
socket.on('connect', () => {
|
||||
$('conn-lost').hidden = true;
|
||||
socket.emit('table:join', { tableId, token: Store.token, name: Store.name }, (res) => {
|
||||
if (res && res.ok) return;
|
||||
if (res && res.error === 'NOT_FOUND') return showNotFound();
|
||||
toast('参加できませんでした: ' + ((res && res.error) || '不明なエラー'));
|
||||
});
|
||||
});
|
||||
socket.on('disconnect', () => { $('conn-lost').hidden = false; });
|
||||
socket.on('connect_error', () => { $('conn-lost').hidden = false; });
|
||||
socket.on('table:state', (s) => {
|
||||
state = s;
|
||||
clockOffset = s.serverNow - Date.now();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function emit(event) {
|
||||
if (!socket) return;
|
||||
socket.emit(event, null, (res) => {
|
||||
if (res && !res.ok) toast(res.error === 'NOT_FOUND' ? 'テーブルが見つかりません' : res.error);
|
||||
});
|
||||
}
|
||||
|
||||
function showNotFound() {
|
||||
$('main').hidden = true;
|
||||
$('action-bar').hidden = true;
|
||||
$('not-found').hidden = false;
|
||||
if (socket) socket.disconnect();
|
||||
}
|
||||
|
||||
// ---------- 描画 ----------
|
||||
function render() {
|
||||
const s = state;
|
||||
$('main').hidden = false;
|
||||
document.title = `${s.name} - Hold'em Night`;
|
||||
$('table-name').textContent = s.name;
|
||||
|
||||
$('level').textContent = `Lv.${s.level}`;
|
||||
$('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`;
|
||||
$('next-blinds').textContent = s.timer.enabled
|
||||
? `次: ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
|
||||
: 'ブラインドタイマーなし';
|
||||
$('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`;
|
||||
|
||||
$('pot').textContent = chips(s.pot);
|
||||
$('phase').textContent = App.PHASE_LABELS[s.phase] || s.phase;
|
||||
$('hand-no').textContent = s.handNumber ? `Hand #${s.handNumber}` : '';
|
||||
|
||||
// ホスト操作
|
||||
const players = s.seats.filter(Boolean).length;
|
||||
$('host-controls').hidden = !s.isHost;
|
||||
$('host-spacer').hidden = s.isHost;
|
||||
$('start').disabled = s.phase !== 'WAITING' || players < 2;
|
||||
$('start').title = players < 2 ? '2人以上で開始できます' : '';
|
||||
$('next-phase').disabled = s.phase === 'WAITING';
|
||||
$('pause').disabled = !s.started;
|
||||
$('pause').textContent = s.paused ? '再開' : '一時停止';
|
||||
|
||||
// 自分
|
||||
const me = s.mySeat >= 0 ? s.seats[s.mySeat] : null;
|
||||
$('action-bar').hidden = !me;
|
||||
$('spectator-note').hidden = Boolean(me);
|
||||
$('leave').title = me ? '退席する' : 'ロビーへ戻る';
|
||||
if (me) $('my-stack').textContent = chips(me.stack);
|
||||
|
||||
renderSeats(s);
|
||||
renderTimer();
|
||||
}
|
||||
|
||||
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
|
||||
function renderSeats(s) {
|
||||
const occupied = s.seats.filter(Boolean);
|
||||
const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat));
|
||||
const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx));
|
||||
const n = ordered.length;
|
||||
|
||||
const container = $('seats');
|
||||
container.innerHTML = ordered
|
||||
.map((p) => {
|
||||
const classes = ['seat'];
|
||||
if (p.seat === s.mySeat) classes.push('me');
|
||||
if (!p.connected) classes.push('offline');
|
||||
const markers = [
|
||||
p.seat === s.buttonSeat ? '<span class="chip-marker dealer" title="ディーラーボタン">D</span>' : '',
|
||||
p.seat === s.sbSeat ? '<span class="blind-marker sb">SB</span>' : '',
|
||||
p.seat === s.bbSeat ? '<span class="blind-marker bb">BB</span>' : '',
|
||||
].join('');
|
||||
return `
|
||||
<div class="${classes.join(' ')}">
|
||||
<div class="avatar">${AVATAR_SVG}${markers}</div>
|
||||
<div class="nameplate">
|
||||
<div class="pname">${p.seat === s.hostSeat ? '<span title="ホスト">👑</span>' : ''}${esc(p.name)}</div>
|
||||
<div class="pstack">${chips(p.stack)}</div>
|
||||
${p.connected ? '' : '<div class="offline-tag">オフライン</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
// 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
|
||||
[...container.children].forEach((el, i) => {
|
||||
const angle = Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1);
|
||||
el.style.left = `${(50 + 38 * Math.cos(angle)).toFixed(2)}%`;
|
||||
el.style.top = `${(50 + 41 * Math.sin(angle)).toFixed(2)}%`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTimer() {
|
||||
const el = $('timer');
|
||||
if (!state) return;
|
||||
const t = state.timer;
|
||||
el.classList.toggle('paused', state.paused);
|
||||
if (!t.enabled) {
|
||||
el.textContent = state.paused ? '⏸ PAUSED' : '';
|
||||
return;
|
||||
}
|
||||
const remaining = t.running ? t.endsAt - (Date.now() + clockOffset) : t.remainingMs;
|
||||
const sec = Math.max(0, Math.ceil(remaining / 1000));
|
||||
const time = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
|
||||
el.textContent = state.paused ? `⏸ ${time}` : time;
|
||||
el.classList.toggle('warn', t.running && sec <= 60);
|
||||
}
|
||||
setInterval(renderTimer, 250);
|
||||
|
||||
// ---------- 操作 ----------
|
||||
$('start').addEventListener('click', () => emit('table:start'));
|
||||
$('next-phase').addEventListener('click', () => emit('table:nextPhase'));
|
||||
$('pause').addEventListener('click', () => emit('table:pause'));
|
||||
|
||||
$('leave').addEventListener('click', () => {
|
||||
const seated = state && state.mySeat >= 0;
|
||||
if (!seated) return (location.href = '/');
|
||||
if (!confirm('テーブルから退席しますか?(チップは失われます)')) return;
|
||||
socket.emit('table:leave', null, () => { location.href = '/'; });
|
||||
});
|
||||
|
||||
$('share').addEventListener('click', async () => {
|
||||
const url = location.origin + '/';
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = url;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
}
|
||||
toast('サイトのリンクをコピーしました');
|
||||
});
|
||||
|
||||
ensureName();
|
||||
})();
|
||||
Reference in new issue
Block a user