Admins can now kick players from the Admin panel so a vanished player can't
stall the table. crud.kick_player removes a player mid-game: it drops their
captaincy, deletes their votes and any Challenge they're part of, and repairs
every other player's pointers to them (pending swap offers are cleared;
delegated Like/Hate questions and recruit technique slots are handed to a
present crewmate, or pool-filled when no one is left). The row is then deleted
— a kicked player's hand simply stops being "in play", so it returns to the
discard automatically. Finally recheck_phase_completion advances the phase if
the kicked player was the last thing it was waiting on.
Auth mirrors set-admin (admin-key on POST /api/game/{gid}/admin/kick). The one
guard is that the last remaining Admin can't be kicked, so the table is never
locked out of its own panel; a vanished creator can still be kicked once a
co-admin exists. The Admin panel gains a per-row Kick button (with a confirm),
shown as "—" for the last admin.
To support re-checking phase completion after a kick, each phase's
"everyone's done -> advance" gate was extracted into an idempotent maybe_*
helper, and recheck_phase_completion dispatches the right one per Game.phase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
7.2 KiB
Svelte
195 lines
7.2 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { apiRequest } from '../lib/api';
|
|
import { setPageTitle } from '../lib/title';
|
|
|
|
export let params = {};
|
|
let gameId = params.id;
|
|
let adminKey = '';
|
|
|
|
let data = null;
|
|
let error = '';
|
|
let copiedPlayerId = null;
|
|
let copiedTimer = null;
|
|
let copiedInvite = false;
|
|
let copiedInviteTimer = null;
|
|
|
|
$: setPageTitle('Admin', data?.game?.crew_name);
|
|
|
|
function rejoinLink(playerId) {
|
|
return `${window.location.origin}/#/game/${gameId}/player/${playerId}`;
|
|
}
|
|
|
|
function inviteLink() {
|
|
return `${window.location.origin}/#/game/${gameId}/join`;
|
|
}
|
|
|
|
function copyRejoinLink(playerId) {
|
|
navigator.clipboard.writeText(rejoinLink(playerId));
|
|
copiedPlayerId = playerId;
|
|
clearTimeout(copiedTimer);
|
|
copiedTimer = setTimeout(() => { copiedPlayerId = null; }, 2000);
|
|
}
|
|
|
|
function copyInviteLink() {
|
|
navigator.clipboard.writeText(inviteLink());
|
|
copiedInvite = true;
|
|
clearTimeout(copiedInviteTimer);
|
|
copiedInviteTimer = setTimeout(() => { copiedInvite = false; }, 2000);
|
|
}
|
|
|
|
onMount(() => {
|
|
// Try to load admin_key from local storage
|
|
adminKey = localStorage.getItem(`admin_key_${gameId}`) || '';
|
|
if (adminKey) {
|
|
loadAdminData();
|
|
}
|
|
});
|
|
|
|
async function loadAdminData() {
|
|
if (!adminKey) return;
|
|
try {
|
|
data = await apiRequest(`/game/${gameId}/admin?key=${encodeURIComponent(adminKey)}`);
|
|
error = '';
|
|
} catch (err) {
|
|
error = err.message;
|
|
data = null;
|
|
}
|
|
}
|
|
|
|
async function setAdmin(playerId, makeAdmin) {
|
|
try {
|
|
await apiRequest(`/game/${gameId}/admin/set-admin`, 'POST', {
|
|
key: adminKey,
|
|
target_player_id: playerId,
|
|
is_admin: makeAdmin,
|
|
});
|
|
await loadAdminData();
|
|
} catch (err) {
|
|
error = err.message;
|
|
}
|
|
}
|
|
|
|
// The last remaining Admin can't be kicked, so the table is never locked out.
|
|
$: adminCount = data ? data.players.filter((p) => p.is_admin).length : 0;
|
|
function canKick(p) {
|
|
return !(p.is_admin && adminCount <= 1);
|
|
}
|
|
|
|
async function kickPlayer(p) {
|
|
const who = p.player_name || p.name;
|
|
if (!confirm(`Kick ${who} from the game? Their cards go to the discard and they'll have to rejoin. This can't be undone.`)) return;
|
|
try {
|
|
await apiRequest(`/game/${gameId}/admin/kick`, 'POST', {
|
|
key: adminKey,
|
|
target_player_id: p.id,
|
|
});
|
|
await loadAdminData();
|
|
} catch (err) {
|
|
error = err.message;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="admin-page">
|
|
<h1>Admin Panel</h1>
|
|
|
|
{#if !data}
|
|
<div class="card">
|
|
<h2 class="mb-4">Enter Admin Key</h2>
|
|
<form on:submit|preventDefault={loadAdminData} class="flex gap-4">
|
|
<input type="text" bind:value={adminKey} class="input-field flex-grow" placeholder="Admin Key" required/>
|
|
<button type="submit" class="btn btn-primary">Login</button>
|
|
</form>
|
|
{#if error}
|
|
<div class="alert alert-danger mt-4">{error}</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="card">
|
|
<h2 class="mb-4">Game: {data.game.crew_name}</h2>
|
|
<p><strong>Phase:</strong> {data.game.phase}</p>
|
|
<p><strong>Admin Key:</strong> <span class="admin-key-chip">{data.game.admin_key}</span></p>
|
|
|
|
{#if error}
|
|
<div class="alert alert-danger mt-4">{error}</div>
|
|
{/if}
|
|
|
|
<h3 class="mt-8 mb-4">Invite Link</h3>
|
|
<p class="info-text">Share this link to let new players join the crew.</p>
|
|
<div class="link-copy-action">
|
|
<input type="text" readonly value={inviteLink()} class="input-field flex-grow" />
|
|
<a href={inviteLink()} class="btn btn-secondary btn-small">Open</a>
|
|
<button on:click={copyInviteLink} class="btn btn-secondary btn-small">
|
|
{copiedInvite ? '✓ Copied' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
|
|
<h3 class="mt-8 mb-4">Players</h3>
|
|
<table class="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Pi-Rat</th>
|
|
<th>Player</th>
|
|
<th>Role</th>
|
|
<th>Status</th>
|
|
<th>Rank</th>
|
|
<th>Admin</th>
|
|
<th>Re-join Link</th>
|
|
<th>Kick</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each data.players as p}
|
|
<tr>
|
|
<td>{p.name}</td>
|
|
<td>{p.player_name || p.name}</td>
|
|
<td>
|
|
{#if p.role === 'deep'} 🌊 Deep
|
|
{:else if p.role === 'pirat'} 🐀 Pi-Rat
|
|
{:else} None
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
{#if p.is_ghost} 👻 Ghost
|
|
{:else if p.is_dead} 💀 Dead
|
|
{:else} Alive
|
|
{/if}
|
|
</td>
|
|
<td>{p.rank}</td>
|
|
<td>
|
|
{#if p.is_creator}
|
|
🗝 Creator
|
|
{:else if p.is_admin}
|
|
<button on:click={() => setAdmin(p.id, false)} class="btn btn-secondary btn-small">Revoke Admin</button>
|
|
{:else}
|
|
<button on:click={() => setAdmin(p.id, true)} class="btn btn-secondary btn-small">Grant Admin</button>
|
|
{/if}
|
|
</td>
|
|
<td>
|
|
<div class="link-copy-action">
|
|
<a href={rejoinLink(p.id)} class="btn btn-secondary btn-small">Open</a>
|
|
<button on:click={() => copyRejoinLink(p.id)} class="btn btn-secondary btn-small">
|
|
{copiedPlayerId === p.id ? '✓ Copied' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{#if canKick(p)}
|
|
<button on:click={() => kickPlayer(p)} class="btn btn-danger btn-small">Kick</button>
|
|
{:else}
|
|
<span class="text-muted">—</span>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="mt-8">
|
|
<a href="/#/game/{gameId}/join" class="btn btn-secondary">Back to Game Join Page</a>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|