Files
electric-cherry/src/routes/server-keys/+page.svelte
T
2026-10-10 00:19:48 -04:00

377 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
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">
<Field label="Search keys" hiddenLabel class="search"
><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…"
/></Field
>
<Field label="Filter keys by game" hiddenLabel
><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
></Field
>
</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><RelativeTime value={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}>
{#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}>
<Field label="Game">
<Select bind:value={game} disabled={busy}
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
></Select
></Field
>
<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 {
display: grid;
gap: var(--space-4);
}
.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: var(--text-xs);
color: var(--text-secondary);
margin-top: 6px;
}
.error {
color: var(--state-danger);
}
</style>