(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: '', cards: '', cpu: '', review: '', person: '', clock: '', trash: '', }; 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 '一時停止中'; if (t.phase === 'WAITING') return '待機中'; return 'プレイ中'; } function rowHtml(t) { const m = MODE[t.mode] || MODE.chips; const full = t.playerCount >= t.maxSeats; const timer = t.timerEnabled ? `${t.intervalMin}分` : 'なし'; return ` ${ICON[m.icon]} ${esc(t.name)} ${statusHtml(t)}${m.label} ${ICON.person}${t.playerCount}/${t.maxSeats} Lv.${t.level} ${chips(t.smallBlind)}/${chips(t.bigBlind)} 開始 ${chips(t.startingStack)} ${ICON.clock}${timer} `; } // ---------- 左へスワイプして削除(管理者) ---------- // 行は指に 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 = `
`; 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 = '
開催中のテーブルはありません。
テーブルを作成して友達を招待しよう。
'; 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); })();