Files
electric-cherry/src/lib/ui/components/DeployServerDialog.svelte
T

323 lines
11 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 { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte';
import Field from './primitives/Field.svelte';
import Input from './primitives/Input.svelte';
import Select from './primitives/Select.svelte';
interface T5MapEntry {
friendly: string;
slug: string;
canonical: string;
}
let { open = $bindable(false) }: { open?: boolean } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let maps = $state<T5MapEntry[]>([]);
let mapsError = $state<string | null>(null);
let mapsLoaded = $state(false);
let gameMode = $state<'zombie'>('zombie');
let mapSlug = $state<string>('');
let plutoKey = $state('');
let lobby = $state('1');
let savedIds = $state<string[]>([]);
let deployedIds = $state<string[]>([]);
let keysLoaded = $state(false);
let deploying = $state(false);
let flash = $state<string | null>(null);
onMount(async () => {
try {
const res = await fetch('/api/maps');
const body = (await res.json()) as { t5Zombie: T5MapEntry[] };
maps = body.t5Zombie;
if (maps.length > 0) mapSlug = maps[0].slug;
} catch (err) {
mapsError = (err as Error).message;
} finally {
mapsLoaded = true;
}
});
let wasOpen = $state(false);
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) {
reset();
dialog.showModal();
} else if (!open && wasOpen) {
if (dialog.open) dialog.close();
}
wasOpen = open;
});
function reset(): void {
plutoKey = '';
void loadKeys();
flash = null;
deploying = false;
if (maps.length > 0 && !maps.some((m) => m.slug === mapSlug)) mapSlug = maps[0].slug;
}
async function loadKeys() {
keysLoaded = false;
try {
const [keysRes, serversRes] = await Promise.all([fetch('/api/server-keys'), fetch('/api/servers')]);
if (!keysRes.ok || !serversRes.ok) throw new Error('Could not load saved keys and server slots. Reopen this dialog to retry.');
savedIds = (await keysRes.json()).keys.map((k: { server_id: string }) => k.server_id);
deployedIds = (await serversRes.json()).servers.map((s: { name: string }) => s.name);
keysLoaded = true;
} catch (err) { flash = (err as Error).message; }
}
function close(): void { open = false; }
function onDialogClick(e: MouseEvent): void {
if (e.target === dialog) close();
}
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
let previewName = $derived(selectedMap ? `t5_${gameMode}_${selectedMap.slug}_${lobby}` : '');
let hasSavedKey = $derived(savedIds.includes(previewName));
let alreadyDeployed = $derived(deployedIds.includes(previewName));
let lobbyOptions = $derived(Array.from({ length: 20 }, (_, i) => {
const value = String(i + 1);
const id = `t5_${gameMode}_${mapSlug}_${value}`;
return { value, label: `${value}${deployedIds.includes(id) ? ' (deployed)' : savedIds.includes(id) ? ' (saved key)' : ''}` };
}));
$effect(() => {
const prefix = `t5_${gameMode}_${mapSlug}_`;
lobby = String(Array.from({ length: 20 }, (_, i) => i + 1)
.find(n => !deployedIds.includes(`${prefix}${n}`)) ?? 1);
});
$effect(() => { mapSlug; lobby; plutoKey = ''; });
let modeOptions = [{ value: 'zombie', label: 'Zombies' }];
let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly })));
async function submit(e: SubmitEvent): Promise<void> {
e.preventDefault();
if (deploying) return;
if (!selectedMap) { flash = 'Pick a map'; return; }
if (!hasSavedKey && plutoKey.trim().length === 0) { flash = 'Pluto server key is required'; return; }
deploying = true;
flash = null;
try {
const res = await fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, lobby: Number(lobby), plutoKey: plutoKey.trim() || undefined })
});
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; error?: string; kind?: string };
if (!body.ok || !body.name) {
flash = body.error ?? 'Deploy failed';
deploying = false;
return;
}
close();
await goto(`/servers/${encodeURIComponent(body.name)}`);
} catch (err) {
flash = (err as Error).message;
deploying = false;
}
}
</script>
<dialog bind:this={dialog} onclick={onDialogClick} onclose={() => (open = false)}>
<form class="dlg" onsubmit={submit}>
<header class="dlg-head">
<h3>Deploy new server</h3>
<button type="button" class="close" onclick={close} aria-label="Close">×</button>
</header>
{#if mapsError}
<div class="alert alert-danger">Failed to load map catalog: {mapsError}</div>
{:else if !mapsLoaded}
<div class="hint">Loading map catalog…</div>
{:else}
<div class="fields">
<Field label="Game">
<div class="readonly">Black Ops 1 (t5)</div>
</Field>
<Field label="Mode">
<Select bind:value={gameMode} options={modeOptions} disabled={deploying} />
</Field>
<Field label="Map" hint="Loads automatically when the server starts. No map overlay needed.">
<Select bind:value={mapSlug} options={mapOptions} disabled={deploying} />
</Field>
<Field label="Lobby number" hint="Choose the same lobby to reuse its server ID and saved key.">
<Select bind:value={lobby} options={lobbyOptions} disabled={deploying} />
</Field>
<Field label="Pluto server key" hint={hasSavedKey ? 'Saved key ready. Leave blank to reuse it, or enter a replacement.' : 'Saved for this server ID and reused on future deployments.'}>
<Input
type="password"
bind:value={plutoKey}
placeholder={hasSavedKey ? "Saved key ready" : "paste key"}
autocomplete="off"
disabled={deploying}
/>
</Field>
</div>
<div class="preview">
<span class="preview-k">Will create</span>
<span class="preview-v">{previewName || '—'}</span>
<span class="preview-sub">{alreadyDeployed ? "Already deployed; choose another lobby" : "Port auto-assigned"}</span>
</div>
<a href="/server-keys" onclick={close}>Manage saved keys</a>
<div class="actions">
<Button variant="ghost" onclick={close} disabled={deploying}>Cancel</Button>
<Button
type="submit"
variant="primary"
disabled={deploying || !selectedMap || !keysLoaded || alreadyDeployed}
loading={deploying}
>
Deploy server
</Button>
</div>
{/if}
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
</form>
</dialog>
<style>
dialog {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: 0;
max-width: 540px;
width: calc(100vw - 40px);
box-shadow: var(--shadow-raised);
}
dialog[open] { animation: dlg-in var(--dur-base) var(--ease-standard); }
@keyframes dlg-in {
from { opacity: 0; transform: translateY(6px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.dlg {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.dlg h3 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.close {
appearance: none;
background: transparent;
border: none;
color: var(--text-tertiary);
font-size: var(--text-xl);
line-height: 1;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: color var(--dur-fast) var(--ease-standard), background-color var(--dur-fast) var(--ease-standard);
}
.close:hover { color: var(--text-primary); background: var(--surface-raised); }
.close:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.fields :global(.field):nth-child(1),
.fields :global(.field):nth-child(5) {
grid-column: 1 / -1;
}
.readonly {
padding: 9px 12px;
color: var(--text-secondary);
font-family: var(--font-body);
font-size: var(--text-sm);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.preview {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-left: 2px solid var(--accent-primary);
border-radius: var(--radius-md);
}
.preview-k {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.preview-v {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
}
.preview-sub {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.alert {
margin: 0;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid transparent;
font-size: var(--text-sm);
}
.alert-danger {
background: rgba(255, 86, 112, 0.08);
border-color: rgba(255, 86, 112, 0.28);
color: var(--state-danger);
}
.hint {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
@media (max-width: 480px) {
.fields { grid-template-columns: 1fr; }
}
</style>