From 0318ee2b5e63dd6d27e8552359640292df8f3f96 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Mon, 19 Oct 2026 23:03:24 +0900 Subject: [PATCH] Let admins delete tables from the lobby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins see a "削除" button on every table in the lobby. It calls the new admin-only DELETE /api/tables/:id (404 for non-admins, same-origin only), which closes the table the same way as when every human leaves. Players and spectators still at the table are told "管理者がテーブルを削除しました。" Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- public/assets/lobby.js | 41 ++++++++++++++++++++++++++++++++++------- public/assets/style.css | 2 ++ public/assets/table.js | 7 ++++--- server/index.js | 14 ++++++++++++-- 5 files changed, 53 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cfaa0c5..8c3ad57 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ http://localhost:4671 を開く。 ## 管理者(ハンドレンジ学習・レビュー付きCPU戦) -`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ)と「📝 CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。管理者は通常のプレイもそのままできる。 +`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ)と「📝 CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。ロビーの各テーブルには「削除」ボタンが出て、どのテーブルでも削除できる(着席者・観戦者には「管理者がテーブルを削除しました」と表示される)。管理者は通常のプレイもそのままできる。 ### レビュー付きCPU戦 diff --git a/public/assets/lobby.js b/public/assets/lobby.js index 1b19938..517cc52 100644 --- a/public/assets/lobby.js +++ b/public/assets/lobby.js @@ -22,11 +22,17 @@ return false; } - // 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクを出す(ページ自体もサーバ側で管理者以外は 404) + let admin = false; + let tables = null; + + // 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除ボタンを出す + // (ページ・API 自体もサーバ側で管理者以外は 404) App.api('GET', '/api/me') .then((me) => { - document.getElementById('range-link').hidden = !me.admin; - document.getElementById('review-link').hidden = !me.admin; + admin = Boolean(me.admin); + document.getElementById('range-link').hidden = !admin; + document.getElementById('review-link').hidden = !admin; + if (tables) render(tables); }) .catch(() => {}); @@ -35,10 +41,27 @@ }); 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); }); + function deleteTable(id, btn) { + const t = tables && tables.find((x) => x.id === id); + if (!t) return; + const players = t.playerCount ? ` +着席中の ${t.playerCount} 人はテーブルから退出させられます。` : ''; + if (!confirm(`「${t.name}」を削除しますか?${players}`)) return; + btn.disabled = true; + App.api('DELETE', '/api/tables/' + encodeURIComponent(id)) + .then(() => App.toast('テーブルを削除しました')) + .catch((err) => { + btn.disabled = false; + App.toast(err.message); + }); + } + const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦', review: '📝 レビュー付きCPU戦' }; function phaseBadge(t) { @@ -47,7 +70,8 @@ return 'PLAYING'; } - function render(tables) { + function render(list) { + tables = list; countEl.textContent = tables.length ? `${tables.length} テーブル` : ''; if (!tables.length) { listEl.innerHTML = '
開催中のテーブルはありません。
テーブルを作成して友達を招待しよう。
'; @@ -69,9 +93,12 @@ ${MODE_LABEL[t.mode] || ''} - +
+ ${admin ? `` : ''} + +
`; }) .join(''); diff --git a/public/assets/style.css b/public/assets/style.css index 93a723e..6db5a9d 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -115,6 +115,7 @@ input.invalid { border-color: var(--red); } .btn-orange { background: var(--orange); } .btn-blue { background: var(--blue); } .btn-green { background: var(--green); } +.btn-red { background: var(--red); } .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%; } @@ -193,6 +194,7 @@ input.invalid { border-color: var(--red); } } .table-item .meta b { color: var(--text); font-weight: 700; } .table-item .meta .gold { color: var(--gold); } +.table-item .buttons { display: flex; gap: 8px; flex-shrink: 0; } .badge { display: inline-block; diff --git a/public/assets/table.js b/public/assets/table.js index 7d175ea..38bd492 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -54,7 +54,7 @@ }); socket.on('disconnect', () => { $('conn-lost').hidden = false; }); socket.on('connect_error', () => { $('conn-lost').hidden = false; }); - socket.on('table:closed', () => showNotFound(true)); + socket.on('table:closed', (p) => showNotFound(true, p && p.reason)); socket.on('table:state', (s) => { state = s; pending = false; @@ -94,10 +94,11 @@ } // closed: プレイヤーが全員退席してテーブルが閉じられた - function showNotFound(closed) { + function showNotFound(closed, reason) { if (closed) { $('not-found-title').textContent = 'テーブルが終了しました'; - $('not-found-text').textContent = 'プレイヤーが全員退席したため、テーブルを閉じました。'; + $('not-found-text').textContent = + reason === 'deleted' ? '管理者がテーブルを削除しました。' : 'プレイヤーが全員退席したため、テーブルを閉じました。'; } $('main').hidden = true; $('action-bar').hidden = true; diff --git a/server/index.js b/server/index.js index d479f74..d05aa06 100644 --- a/server/index.js +++ b/server/index.js @@ -173,6 +173,15 @@ app.post('/api/tables', (req, res) => { res.json({ id: table.id }); }); +// 管理者はどのテーブルでも削除できる(着席者・観戦者にはテーブルが閉じられたことを知らせる) +app.delete('/api/tables/:id', auth.requireAdmin, (req, res) => { + const table = tables.get(req.params.id); + if (!table) return res.status(404).json({ error: 'テーブルが見つかりません' }); + console.log(`[admin] テーブル「${table.name}」を削除`); + closeTable(table, null, 'deleted'); + res.json({ ok: true }); +}); + app.use((req, res) => res.status(404).type('text/plain').send('Not Found')); // 内部情報(スタックトレース等)を返さない @@ -222,12 +231,13 @@ function changed(table) { } // テーブルを削除し、残っている観戦者に知らせる(leaver: 退席した本人。すでにロビーへ戻るので知らせない) -function closeTable(table, leaver) { +// reason: 'empty'(人間が全員退席した)/ 'deleted'(管理者が削除した) +function closeTable(table, leaver, reason = 'empty') { tables.delete(table.id); for (const s of roomSockets(table.id)) { s.leave(roomOf(table.id)); if (s.data.tableId === table.id) s.data.tableId = null; - if (s !== leaver) s.emit('table:closed'); + if (s !== leaver) s.emit('table:closed', { reason }); } broadcastLobby(); }