Files
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

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);
})();