377 lines
13 KiB
Svelte
377 lines
13 KiB
Svelte
<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>
|