Let admins delete tables from the lobby
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f2515a9fa
commit
0318ee2b5e
5 files changed
+50
-10
No files matched your search
@@ -64,7 +64,7 @@ http://localhost:4671 を開く。
|
|||||||
|
|
||||||
## 管理者(ハンドレンジ学習・レビュー付きCPU戦)
|
## 管理者(ハンドレンジ学習・レビュー付きCPU戦)
|
||||||
|
|
||||||
`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ)と「📝 CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。管理者は通常のプレイもそのままできる。
|
`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」(`/range`:レンジ表と色・分類クイズ)と「📝 CPU戦のレビュー」(`/review`)が表示され、テーブル作成で「レビュー付きCPU戦」を選べる。ロビーの各テーブルには「削除」ボタンが出て、どのテーブルでも削除できる(着席者・観戦者には「管理者がテーブルを削除しました」と表示される)。管理者は通常のプレイもそのままできる。
|
||||||
|
|
||||||
### レビュー付きCPU戦
|
### レビュー付きCPU戦
|
||||||
|
|
||||||
|
|||||||
+31
-4
@@ -22,11 +22,17 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクを出す(ページ自体もサーバ側で管理者以外は 404)
|
let admin = false;
|
||||||
|
let tables = null;
|
||||||
|
|
||||||
|
// 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除ボタンを出す
|
||||||
|
// (ページ・API 自体もサーバ側で管理者以外は 404)
|
||||||
App.api('GET', '/api/me')
|
App.api('GET', '/api/me')
|
||||||
.then((me) => {
|
.then((me) => {
|
||||||
document.getElementById('range-link').hidden = !me.admin;
|
admin = Boolean(me.admin);
|
||||||
document.getElementById('review-link').hidden = !me.admin;
|
document.getElementById('range-link').hidden = !admin;
|
||||||
|
document.getElementById('review-link').hidden = !admin;
|
||||||
|
if (tables) render(tables);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
@@ -35,10 +41,27 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
listEl.addEventListener('click', (e) => {
|
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]');
|
const btn = e.target.closest('[data-join]');
|
||||||
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.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戦' };
|
const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦', review: '📝 レビュー付きCPU戦' };
|
||||||
|
|
||||||
function phaseBadge(t) {
|
function phaseBadge(t) {
|
||||||
@@ -47,7 +70,8 @@
|
|||||||
return '<span class="badge live">PLAYING</span>';
|
return '<span class="badge live">PLAYING</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(tables) {
|
function render(list) {
|
||||||
|
tables = list;
|
||||||
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
|
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
|
||||||
if (!tables.length) {
|
if (!tables.length) {
|
||||||
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
|
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
|
||||||
@@ -69,9 +93,12 @@
|
|||||||
<span>${MODE_LABEL[t.mode] || ''}</span>
|
<span>${MODE_LABEL[t.mode] || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</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="満席(観戦のみ)"' : ''}>
|
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
|
||||||
${full ? '観戦' : '参加'}
|
${full ? '観戦' : '参加'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ input.invalid { border-color: var(--red); }
|
|||||||
.btn-orange { background: var(--orange); }
|
.btn-orange { background: var(--orange); }
|
||||||
.btn-blue { background: var(--blue); }
|
.btn-blue { background: var(--blue); }
|
||||||
.btn-green { background: var(--green); }
|
.btn-green { background: var(--green); }
|
||||||
|
.btn-red { background: var(--red); }
|
||||||
.btn-dark { background: var(--panel-2); color: var(--text); }
|
.btn-dark { background: var(--panel-2); color: var(--text); }
|
||||||
.btn-ghost { background: transparent; color: var(--muted); border: 2px solid var(--line); }
|
.btn-ghost { background: transparent; color: var(--muted); border: 2px solid var(--line); }
|
||||||
.btn-block { width: 100%; }
|
.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 b { color: var(--text); font-weight: 700; }
|
||||||
.table-item .meta .gold { color: var(--gold); }
|
.table-item .meta .gold { color: var(--gold); }
|
||||||
|
.table-item .buttons { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
});
|
});
|
||||||
socket.on('disconnect', () => { $('conn-lost').hidden = false; });
|
socket.on('disconnect', () => { $('conn-lost').hidden = false; });
|
||||||
socket.on('connect_error', () => { $('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) => {
|
socket.on('table:state', (s) => {
|
||||||
state = s;
|
state = s;
|
||||||
pending = false;
|
pending = false;
|
||||||
@@ -94,10 +94,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// closed: プレイヤーが全員退席してテーブルが閉じられた
|
// closed: プレイヤーが全員退席してテーブルが閉じられた
|
||||||
function showNotFound(closed) {
|
function showNotFound(closed, reason) {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
$('not-found-title').textContent = 'テーブルが終了しました';
|
$('not-found-title').textContent = 'テーブルが終了しました';
|
||||||
$('not-found-text').textContent = 'プレイヤーが全員退席したため、テーブルを閉じました。';
|
$('not-found-text').textContent =
|
||||||
|
reason === 'deleted' ? '管理者がテーブルを削除しました。' : 'プレイヤーが全員退席したため、テーブルを閉じました。';
|
||||||
}
|
}
|
||||||
$('main').hidden = true;
|
$('main').hidden = true;
|
||||||
$('action-bar').hidden = true;
|
$('action-bar').hidden = true;
|
||||||
|
|||||||
+12
-2
@@ -173,6 +173,15 @@ app.post('/api/tables', (req, res) => {
|
|||||||
res.json({ id: table.id });
|
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'));
|
app.use((req, res) => res.status(404).type('text/plain').send('Not Found'));
|
||||||
|
|
||||||
// 内部情報(スタックトレース等)を返さない
|
// 内部情報(スタックトレース等)を返さない
|
||||||
@@ -222,12 +231,13 @@ function changed(table) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// テーブルを削除し、残っている観戦者に知らせる(leaver: 退席した本人。すでにロビーへ戻るので知らせない)
|
// テーブルを削除し、残っている観戦者に知らせる(leaver: 退席した本人。すでにロビーへ戻るので知らせない)
|
||||||
function closeTable(table, leaver) {
|
// reason: 'empty'(人間が全員退席した)/ 'deleted'(管理者が削除した)
|
||||||
|
function closeTable(table, leaver, reason = 'empty') {
|
||||||
tables.delete(table.id);
|
tables.delete(table.id);
|
||||||
for (const s of roomSockets(table.id)) {
|
for (const s of roomSockets(table.id)) {
|
||||||
s.leave(roomOf(table.id));
|
s.leave(roomOf(table.id));
|
||||||
if (s.data.tableId === table.id) s.data.tableId = null;
|
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();
|
broadcastLobby();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user