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>
268 lines
11 KiB
JavaScript
268 lines
11 KiB
JavaScript
(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');
|
|
const footerEl = document.getElementById('list-footer');
|
|
|
|
Fluid.navBar(document.getElementById('nav'), document.getElementById('large-title'));
|
|
|
|
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;
|
|
}
|
|
|
|
let admin = false;
|
|
let tables = null;
|
|
|
|
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除(左へスワイプ)を出す
|
|
// (ページ・API 自体もサーバ側で管理者以外は 404)
|
|
App.api('GET', '/api/me')
|
|
.then((me) => {
|
|
admin = Boolean(me.admin);
|
|
document.getElementById('admin-group').hidden = !admin;
|
|
if (tables) render(tables);
|
|
})
|
|
.catch(() => {});
|
|
|
|
document.getElementById('create').addEventListener('click', () => {
|
|
if (requireName()) location.href = '/create';
|
|
});
|
|
|
|
listEl.addEventListener('click', (e) => {
|
|
const del = e.target.closest('[data-delete]');
|
|
if (del) return deleteTable(del.dataset.delete, del);
|
|
const btn = e.target.closest('[data-join]');
|
|
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
|
|
});
|
|
|
|
async function deleteTable(id, btn) {
|
|
const t = tables && tables.find((x) => x.id === id);
|
|
if (!t) return;
|
|
const players = t.playerCount ? `
|
|
着席中の ${t.playerCount} 人はテーブルから退出させられます。` : '';
|
|
const ok = await App.confirm({
|
|
title: 'テーブルを削除',
|
|
message: `「${t.name}」を削除しますか?${players}`,
|
|
ok: '削除する',
|
|
danger: true,
|
|
});
|
|
const row = btn.closest('.swipe-row');
|
|
if (!ok || !document.contains(btn)) {
|
|
if (row && row.swipe) row.swipe.close();
|
|
return;
|
|
}
|
|
btn.disabled = true;
|
|
App.api('DELETE', '/api/tables/' + encodeURIComponent(id))
|
|
.then(() => App.toast('テーブルを削除しました'))
|
|
.catch((err) => {
|
|
btn.disabled = false;
|
|
if (row && row.swipe) row.swipe.close();
|
|
App.toast(err.message);
|
|
});
|
|
}
|
|
|
|
const ICON = {
|
|
chips:
|
|
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="7" rx="7" ry="3"/><path d="M5 7v5c0 1.7 3.1 3 7 3s7-1.3 7-3V7"/><path d="M5 12v5c0 1.7 3.1 3 7 3s7-1.3 7-3v-5"/></svg>',
|
|
cards:
|
|
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 5.2l-3.9.7a2 2 0 0 0-1.6 2.3l1.8 10a2 2 0 0 0 2.3 1.6l1.4-.3"/><rect x="10.5" y="3.5" width="10" height="15" rx="2"/></svg>',
|
|
cpu:
|
|
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2.5"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/></svg>',
|
|
review:
|
|
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
|
|
person: '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4.5 20a7.5 7.5 0 0 1 15 0"/></svg>',
|
|
clock: '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
|
|
trash:
|
|
'<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 7h15M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12a2 2 0 0 0 2 1.8h5a2 2 0 0 0 2-1.8l1-12M10.5 11v6M13.5 11v6"/></svg>',
|
|
};
|
|
|
|
const MODE = {
|
|
chips: { label: 'キャッシュオンリー', icon: 'chips', tone: 'green' },
|
|
friends: { label: '友人戦', icon: 'cards', tone: 'blue' },
|
|
cpu: { label: 'CPU戦', icon: 'cpu', tone: 'indigo' },
|
|
review: { label: 'レビュー付きCPU戦', icon: 'review', tone: 'orange' },
|
|
};
|
|
|
|
function statusHtml(t) {
|
|
if (t.paused) return '<span class="status paused">一時停止中</span>';
|
|
if (t.phase === 'WAITING') return '<span class="status waiting">待機中</span>';
|
|
return '<span class="status live">プレイ中</span>';
|
|
}
|
|
|
|
function rowHtml(t) {
|
|
const m = MODE[t.mode] || MODE.chips;
|
|
const full = t.playerCount >= t.maxSeats;
|
|
const timer = t.timerEnabled ? `${t.intervalMin}分` : 'なし';
|
|
return `
|
|
<span class="cell-icon tone-${m.tone}">${ICON[m.icon]}</span>
|
|
<span class="table-row-main">
|
|
<span class="name">${esc(t.name)}</span>
|
|
<span class="table-row-meta">${statusHtml(t)}<span>${m.label}</span></span>
|
|
<span class="table-row-meta">
|
|
<span class="meta-item">${ICON.person}${t.playerCount}/${t.maxSeats}</span>
|
|
<span>Lv.${t.level} ${chips(t.smallBlind)}/${chips(t.bigBlind)}</span>
|
|
<span>開始 ${chips(t.startingStack)}</span>
|
|
<span class="meta-item">${ICON.clock}${timer}</span>
|
|
</span>
|
|
</span>
|
|
<button type="button" class="btn-get" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>${full ? '観戦' : '参加'}</button>`;
|
|
}
|
|
|
|
// ---------- 左へスワイプして削除(管理者) ----------
|
|
// 行は指に 1:1 で付いてきて、削除ボタンの幅を越えるとゴムのように抵抗する。
|
|
// 離したときは速度から止まる位置を先読みし、半分を越えていれば開いたまま止める
|
|
const ACTION_WIDTH = 88;
|
|
let openRow = null;
|
|
|
|
function attachSwipe(row) {
|
|
const content = row.querySelector('.table-row');
|
|
const action = row.querySelector('.swipe-action');
|
|
const x = new Fluid.Spring(0, { precision: 0.2 });
|
|
const tracker = new Fluid.VelocityTracker();
|
|
let drag = null;
|
|
let suppressClick = false;
|
|
|
|
x.onUpdate = (v) => {
|
|
content.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
|
|
row.style.setProperty('--reveal', Fluid.clamp(-v / ACTION_WIDTH, 0, 1).toFixed(3));
|
|
};
|
|
const settle = (open, velocity) => {
|
|
x.to(open ? -ACTION_WIDTH : 0, { damping: velocity ? 0.8 : 1, response: 0.3, velocity });
|
|
if (open) openRow = row;
|
|
else if (openRow === row) openRow = null;
|
|
};
|
|
row.swipe = { close: () => settle(false) };
|
|
|
|
content.addEventListener('pointerdown', (e) => {
|
|
if (!admin || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
|
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
|
|
tracker.reset();
|
|
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
|
});
|
|
content.addEventListener('pointermove', (e) => {
|
|
if (!drag || e.pointerId !== drag.id) return;
|
|
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
|
if (!drag.active) {
|
|
const dx = e.clientX - drag.x;
|
|
const dy = e.clientY - drag.y;
|
|
if (Math.hypot(dx, dy) < 10) return;
|
|
// 縦の動きはページのスクロールに任せる
|
|
if (Math.abs(dy) > Math.abs(dx)) {
|
|
drag = null;
|
|
return;
|
|
}
|
|
drag.active = true;
|
|
drag.x = e.clientX;
|
|
x.stop();
|
|
drag.origin = x.value;
|
|
content.setPointerCapture(e.pointerId);
|
|
if (openRow && openRow !== row) openRow.swipe.close();
|
|
}
|
|
let raw = drag.origin + (e.clientX - drag.x);
|
|
if (raw > 0) raw = Fluid.rubberband(raw, row.offsetWidth);
|
|
else if (raw < -ACTION_WIDTH) raw = -ACTION_WIDTH - Fluid.rubberband(-ACTION_WIDTH - raw, row.offsetWidth);
|
|
x.set(raw);
|
|
});
|
|
const end = (e) => {
|
|
if (!drag || e.pointerId !== drag.id) return;
|
|
const wasActive = drag.active;
|
|
drag = null;
|
|
if (!wasActive) return;
|
|
suppressClick = true;
|
|
setTimeout(() => {
|
|
suppressClick = false;
|
|
}, 0);
|
|
tracker.add(e.clientX, e.clientY, e.timeStamp);
|
|
const v = tracker.velocity().x;
|
|
settle(x.value + Fluid.project(v) < -ACTION_WIDTH / 2, v);
|
|
};
|
|
content.addEventListener('pointerup', end);
|
|
content.addEventListener('pointercancel', end);
|
|
content.addEventListener(
|
|
'click',
|
|
(e) => {
|
|
// スワイプの後のクリックや、開いている行のタップ(参加ボタン以外)は「閉じる」だけにする
|
|
const tapOnOpenRow = openRow === row && !e.target.closest('[data-join]');
|
|
if (!suppressClick && !tapOnOpenRow) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!suppressClick) row.swipe.close();
|
|
},
|
|
true
|
|
);
|
|
// キーボードでは削除ボタンにフォーカスが来たら開く
|
|
action.addEventListener('focus', () => settle(true));
|
|
action.addEventListener('blur', () => settle(false));
|
|
}
|
|
|
|
document.addEventListener('pointerdown', (e) => {
|
|
if (openRow && !openRow.contains(e.target)) openRow.swipe.close();
|
|
});
|
|
|
|
// ---------- 一覧(行は使い回す。スワイプの途中で作り直さない) ----------
|
|
const rows = new Map();
|
|
|
|
function rowEl(id) {
|
|
let row = rows.get(id);
|
|
if (row) return row;
|
|
row = document.createElement('div');
|
|
row.className = 'swipe-row';
|
|
row.innerHTML = `<button type="button" class="swipe-action" data-delete="${esc(id)}">${ICON.trash}<span>削除</span></button><div class="table-row"></div>`;
|
|
attachSwipe(row);
|
|
rows.set(id, row);
|
|
return row;
|
|
}
|
|
|
|
function render(list) {
|
|
tables = list;
|
|
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
|
|
footerEl.hidden = !admin || !tables.length;
|
|
if (!tables.length) {
|
|
rows.clear();
|
|
openRow = null;
|
|
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
|
|
return;
|
|
}
|
|
const empty = listEl.querySelector('.empty');
|
|
if (empty) empty.remove();
|
|
const ids = new Set(tables.map((t) => t.id));
|
|
for (const [id, row] of rows) {
|
|
if (ids.has(id)) continue;
|
|
if (openRow === row) openRow = null;
|
|
row.remove();
|
|
rows.delete(id);
|
|
}
|
|
tables.forEach((t, i) => {
|
|
const row = rowEl(t.id);
|
|
const html = rowHtml(t);
|
|
const content = row.querySelector('.table-row');
|
|
if (content.html !== html) {
|
|
content.html = html;
|
|
content.innerHTML = html;
|
|
}
|
|
const action = row.querySelector('.swipe-action');
|
|
action.hidden = !admin;
|
|
action.tabIndex = admin ? 0 : -1;
|
|
if (listEl.children[i] !== row) listEl.insertBefore(row, listEl.children[i] || null);
|
|
});
|
|
}
|
|
|
|
const socket = App.connectSocket();
|
|
socket.on('connect', () => socket.emit('lobby:subscribe'));
|
|
socket.on('lobby:tables', render);
|
|
})();
|