158 lines
9.9 KiB
Svelte
158 lines
9.9 KiB
Svelte
<script lang="ts">
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import { onMount } from 'svelte';
|
||
import { page } from '$app/stores';
|
||
import { goto } from '$app/navigation';
|
||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||
import { confirmAction } from '$ui/confirm.js';
|
||
import { serverTitle } from '$ui/server-labels.js';
|
||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||
import Button from '$lib/ui/components/primitives/Button.svelte';
|
||
import Field from '$lib/ui/components/primitives/Field.svelte';
|
||
import Input from '$lib/ui/components/primitives/Input.svelte';
|
||
|
||
interface SavedKey { id: string; game: ZombieGame; label: string; assigned_servers: string[]; updated_at: string }
|
||
const games: ZombieGame[] = ['t5', 't6'];
|
||
let open = $state(false);
|
||
let search = $state('');
|
||
let gameFilter = $state('');
|
||
let limit = $state(25);
|
||
let keys = $state<SavedKey[]>([]);
|
||
let game = $state<ZombieGame>('t5');
|
||
let label = $state('');
|
||
let plutoKey = $state('');
|
||
let busy = $state(false);
|
||
let loading = $state(true);
|
||
let error = $state('');
|
||
let notice = $state('');
|
||
$effect(() => {
|
||
search = $page.url.searchParams.get('search') ?? $page.url.searchParams.get('server') ?? '';
|
||
gameFilter = $page.url.searchParams.get('game') ?? '';
|
||
limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25);
|
||
});
|
||
const filtered = $derived(keys.filter(key => (!gameFilter || key.game === gameFilter)
|
||
&& [key.game, key.label, ...key.assigned_servers.flatMap(name => [name, serverTitle(name)])].join(' ').toLowerCase().includes(search.toLowerCase())));
|
||
function saveSearch() {
|
||
const url = new URL($page.url); url.searchParams.delete('server');
|
||
if (search) url.searchParams.set('search', search); else url.searchParams.delete('search');
|
||
if (gameFilter) url.searchParams.set('game', gameFilter); else url.searchParams.delete('game');
|
||
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
|
||
void goto(url, { replaceState: true, keepFocus: true, noScroll: true });
|
||
}
|
||
async function load() {
|
||
const res = await fetch('/api/server-keys');
|
||
if (!res.ok) throw new Error('Could not load server keys');
|
||
keys = (await res.json()).keys;
|
||
}
|
||
async function reload() { try { await load(); error = ''; } catch (e) { error = (e as Error).message; } finally { loading = false; } }
|
||
onMount(() => { void reload(); });
|
||
function addKey() {
|
||
plutoKey = ''; label = ''; error = '';
|
||
if (gameFilter === 't5' || gameFilter === 't6') game = gameFilter;
|
||
open = true;
|
||
}
|
||
async function save(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
if (!plutoKey.trim() || busy) return;
|
||
busy = true; error = ''; notice = '';
|
||
try {
|
||
const res = await fetch('/api/server-keys', {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ game, label, plutoKey })
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
|
||
plutoKey = ''; label = ''; open = false; notice = 'Key saved to the game pool.'; await load();
|
||
} catch (e) { error = (e as Error).message; }
|
||
finally { busy = false; }
|
||
}
|
||
async function remove(key: SavedKey) {
|
||
if (key.assigned_servers.length) return;
|
||
if (!await confirmAction({ title: 'Remove saved key?', message: `Remove ${key.label || 'this key'} from the ${key.game.toUpperCase()} pool?`, confirmLabel: 'Remove key', danger: true })) return;
|
||
busy = true; error = ''; notice = '';
|
||
try {
|
||
const res = await fetch('/api/server-keys', {
|
||
method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: key.id })
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? 'Could not remove key');
|
||
notice = 'Saved key removed.'; await load();
|
||
} catch (e) { error = (e as Error).message; }
|
||
finally { busy = false; }
|
||
}
|
||
</script>
|
||
|
||
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
|
||
<div class="page">
|
||
<SectionHeader title="Keys" subtitle="Pool Plutonium keys by game. Any available key can launch any map or lobby in that game.">
|
||
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
|
||
</SectionHeader>
|
||
<p class="key-help">A key stays assigned while its server exists, including when stopped, and becomes available after removal is confirmed. Rebuilds keep the same key. Use the optional label to record its Plutonium hostname; saving a label does not change that hostname.</p>
|
||
{#if !loading}<div class="pools" aria-label="Game key pools">{#each games as poolGame}
|
||
{@const pool = keys.filter(key => key.game === poolGame)}
|
||
<div><strong>{poolGame === 't5' ? 'Black Ops I' : 'Black Ops II'}</strong><span>{pool.filter(key => !key.assigned_servers.length).length} available · {pool.filter(key => key.assigned_servers.length).length} assigned · {pool.length} total</span></div>
|
||
{/each}</div>{/if}
|
||
<div class="ui-toolbar">
|
||
<input type="search" aria-label="Search keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search labels or assigned servers…" />
|
||
<select aria-label="Filter keys by game" bind:value={gameFilter} onchange={() => { limit = 25; saveSearch(); }}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
|
||
</div>
|
||
{#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if}
|
||
{#if notice}<p role="status">{notice}</p>{/if}
|
||
<h2>Saved keys</h2>
|
||
{#if loading}<p>Loading keys…</p>
|
||
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add keys here. Launches automatically use an available key from their game’s pool." />
|
||
{:else}
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>Game / label</th><th>Availability</th><th>Last saved</th><th>Actions</th></tr></thead>
|
||
<tbody>{#each filtered.slice(0, limit) as key (key.id)}
|
||
<tr>
|
||
<td><strong>{key.game.toUpperCase()}</strong><span class="key-label">{key.label || 'Unlabeled key'}</span></td>
|
||
<td>{#if key.assigned_servers.length}<span>Assigned</span>{#each key.assigned_servers as name}<div class="assignment"><ServerIdentity {name} /><code>{name}</code></div>{/each}{:else}<span class="available">Available</span>{/if}</td>
|
||
<td>{$timeFormat.dateTime(key.updated_at)}</td>
|
||
<td><Button ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`} disabled={busy || !!key.assigned_servers.length} onclick={() => remove(key)}>Remove</Button>{#if key.assigned_servers.length}<small>In use</small>{/if}</td>
|
||
</tr>
|
||
{/each}</tbody>
|
||
</table></div>
|
||
{/if}
|
||
<ListFooter total={filtered.length} bind:limit onchange={saveSearch} label="keys" />
|
||
</div>
|
||
<Dialog bind:open title="Add key" busy={busy}>
|
||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||
<form onsubmit={save}>
|
||
<label>Game<select bind:value={game} disabled={busy}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
|
||
<Field label="Label (optional)" hint="Record the hostname you chose in Plutonium, or another name that helps identify this key.">
|
||
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
|
||
</Field>
|
||
<Field label="Plutonium server key" hint="Available to any new lobby in this game. Existing credentials are never displayed.">
|
||
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
|
||
</Field>
|
||
<div class="actions"><Button type="submit" variant="primary" disabled={busy || loading || !plutoKey.trim()} loading={busy}>Save key</Button><Button onclick={() => open = false} disabled={busy}>Cancel</Button></div>
|
||
</form>
|
||
</Dialog>
|
||
<style>
|
||
.page { display: grid; gap: 24px; }
|
||
.key-help { margin: 0; font-size: var(--text-sm); line-height: 1.65; }
|
||
.pools { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
|
||
.pools > div { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||
.pools span, .key-label, small { color: var(--text-secondary); font-size: var(--text-sm); }
|
||
.key-label, small { display: block; margin-top: 6px; }
|
||
.assignment { margin-top: 8px; }
|
||
.available { color: var(--state-online); }
|
||
h2 { font-size: var(--text-md); }
|
||
p { color: var(--text-secondary); }
|
||
form, label { display: grid; gap: var(--space-4); }
|
||
label { gap: 8px; font-size: 14px; }
|
||
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }
|
||
.actions { display: flex; gap: var(--space-2); }
|
||
.table-wrap { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; text-align: left; }
|
||
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
|
||
th { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||
code { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
|
||
.error { color: var(--state-danger); }
|
||
</style>
|