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>
This commit is contained in:
1 parent
d5ef2d310f
commit
c3fbfdd12e
18 files changed
+4877
-1278
No files matched your search
+185
-34
@@ -6,6 +6,9 @@
|
||||
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', () => {
|
||||
@@ -25,13 +28,12 @@
|
||||
let admin = false;
|
||||
let tables = null;
|
||||
|
||||
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除ボタンを出す
|
||||
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除(左へスワイプ)を出す
|
||||
// (ページ・API 自体もサーバ側で管理者以外は 404)
|
||||
App.api('GET', '/api/me')
|
||||
.then((me) => {
|
||||
admin = Boolean(me.admin);
|
||||
document.getElementById('range-link').hidden = !admin;
|
||||
document.getElementById('review-link').hidden = !admin;
|
||||
document.getElementById('admin-group').hidden = !admin;
|
||||
if (tables) render(tables);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -58,56 +60,205 @@
|
||||
ok: '削除する',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok || !document.contains(btn)) return;
|
||||
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 MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦', review: '📝 レビュー付きCPU戦' };
|
||||
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>',
|
||||
};
|
||||
|
||||
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>';
|
||||
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;
|
||||
}
|
||||
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>
|
||||
<span>${MODE_LABEL[t.mode] || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
${admin ? `<button class="btn btn-red btn-sm" data-delete="${esc(t.id)}" title="テーブルを削除">削除</button>` : ''}
|
||||
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
|
||||
${full ? '観戦' : '参加'}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user