Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+302 -78
View File
@@ -15,7 +15,13 @@
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 }
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('');
@@ -34,13 +40,29 @@
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())));
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');
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() {
@@ -48,110 +70,312 @@
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(); });
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 = '';
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 = '';
busy = true;
error = '';
notice = '';
try {
const res = await fetch('/api/server-keys', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
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; }
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 = '';
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 })
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; }
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>
<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><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>
{#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={busy}>
<Dialog bind:open title="Add key" {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</option><option value="t6">Black Ops II</option></select></label>
<Field label="Label (optional)" hint="Record the hostname you chose in Plutonium, or another name that helps identify this key.">
<label
>Game<select bind:value={game} disabled={busy}
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</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
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>
<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: var(--text-base); }
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: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
.error { color: var(--state-danger); }
.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: var(--text-base);
}
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: var(--text-xs);
color: var(--text-secondary);
margin-top: 6px;
}
.error {
color: var(--state-danger);
}
</style>