323 lines
11 KiB
Svelte
323 lines
11 KiB
Svelte
<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>
|