203 lines
19 KiB
Svelte
203 lines
19 KiB
Svelte
<script lang="ts">
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import { onMount, tick } from 'svelte';
|
||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||
import { goto } from '$app/navigation';
|
||
import Button from './primitives/Button.svelte';
|
||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||
import DeployCollectionDialog from './DeployCollectionDialog.svelte';
|
||
import NativeHostSelection from './NativeHostSelection.svelte';
|
||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
|
||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||
|
||
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '', onblueprint }:
|
||
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string; onblueprint?: () => void } = $props();
|
||
let dialog: HTMLDialogElement | undefined = $state();
|
||
let game = $state<ZombieGame>('t5');
|
||
let mapSlug = $state('');
|
||
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
||
let blueprints = $state<BlueprintSummary[]>([]);
|
||
let blueprintId = $state('');
|
||
let collectionOpen = $state(false);
|
||
let maps = $derived(catalogs[game]);
|
||
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
|
||
let preview = $state<LaunchPreview | null>(null);
|
||
let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: '' });
|
||
let hostReady = $state(false);
|
||
let plutoKey = $state('');
|
||
let busy = $state(false);
|
||
let loaded = $state(false);
|
||
let error = $state('');
|
||
let blueprintName = $state('');
|
||
let description = $state('');
|
||
let savedName = $state('');
|
||
let saving = $state(false);
|
||
let wasOpen = false;
|
||
let session = 0;
|
||
let keyNotice = $state('');
|
||
let selectionNotice = $state('');
|
||
let now = $state(Date.now());
|
||
let heading: HTMLHeadingElement;
|
||
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||
|
||
$effect(() => {
|
||
if (!dialog) return;
|
||
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
|
||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||
});
|
||
|
||
async function initialize() {
|
||
const current = ++session;
|
||
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; selectionNotice = '';
|
||
blueprintName = ''; description = ''; savedName = ''; saving = false;
|
||
blueprintId = initialBlueprintId;
|
||
game = initialGame;
|
||
mapSlug = initialMapSlug;
|
||
hostSelection = { hostId: '', libraryIds: {}, image: '' }; hostReady = false;
|
||
try {
|
||
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
|
||
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.');
|
||
const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]);
|
||
if (current !== session) return;
|
||
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie };
|
||
blueprints = blueprintBody.blueprints;
|
||
if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
|
||
if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This collection is no longer available. Close this dialog and choose another starting point.');
|
||
if (sourceDeploymentId) {
|
||
const source = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`).then(response => response.json());
|
||
if (current !== session) return;
|
||
if (!source.recipe) throw new Error('The original launch recipe is unavailable.');
|
||
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
|
||
}
|
||
loaded = true;
|
||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||
}
|
||
|
||
function selectionChanged() {
|
||
if (preview) selectionNotice = 'The host or game files changed. Review the launch again with the latest files.';
|
||
preview = null; plutoKey = ''; keyNotice = '';
|
||
}
|
||
|
||
async function review() {
|
||
if (busy) return;
|
||
if (!sourceDeploymentId && selectedBlueprint) {
|
||
open = false; collectionOpen = true; return;
|
||
}
|
||
if (!hostReady) { error = 'Choose a host and wait for its latest game files and runtime to finish automatic setup.'; return; }
|
||
const current = session;
|
||
const previousName = preview?.name;
|
||
busy = true; error = ''; savedName = ''; keyNotice = ''; selectionNotice = '';
|
||
try {
|
||
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
|
||
hostId: hostSelection.hostId, libraryId: hostSelection.libraryIds[game], image: hostSelection.image,
|
||
bindAddress: hostSelection.bindAddress || undefined, publicAddress: hostSelection.publicAddress || undefined, publicPort: hostSelection.publicPort }) });
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
||
if (current === session) {
|
||
if (previousName && previousName !== body.name) { plutoKey = ''; keyNotice = 'The available server ID changed. Enter the key for the new ID below; the previous key was not transferred.'; }
|
||
if (body.hasSavedKey) plutoKey = '';
|
||
preview = body; now = Date.now();
|
||
await tick(); heading?.focus();
|
||
}
|
||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||
finally { if (current === session) busy = false; }
|
||
}
|
||
|
||
async function launch() {
|
||
if (!preview || !preview.ready || !hostReady || expired || busy) return;
|
||
busy = true; error = '';
|
||
try {
|
||
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ game: preview.recipe.game, gameMode: 'zombie', mapSlug: preview.recipe.mapSlug,
|
||
expectedName: preview.name, previewId: preview.id, plutoKey: plutoKey.trim() || undefined }) });
|
||
const body = await res.json();
|
||
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.');
|
||
open = false;
|
||
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
||
} catch (err) { error = (err as Error).message; }
|
||
finally { busy = false; }
|
||
}
|
||
|
||
async function save() {
|
||
if (!preview || saving) return;
|
||
const current = session;
|
||
saving = true; error = '';
|
||
try {
|
||
const res = await fetch('/api/blueprints', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) });
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
|
||
if (current === session) { savedName = body.blueprint.name; blueprints = [...blueprints, body.blueprint]; onblueprint?.(); }
|
||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||
finally { if (current === session) saving = false; }
|
||
}
|
||
</script>
|
||
|
||
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
|
||
<div class="composer">
|
||
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header>
|
||
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
|
||
{#if error}<div class="error" role="alert">{error}</div>{/if}
|
||
{#if selectionNotice}<p class="muted" role="status">{selectionNotice}</p>{/if}
|
||
{#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy || saving} network onchange={selectionChanged} />{/if}
|
||
{#if !loaded}
|
||
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p>
|
||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||
{:else if preview}
|
||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Keys stay entered when the server ID is unchanged.</p>{/if}
|
||
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From collection: {preview.blueprintName}</p>{/if}</section>
|
||
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
|
||
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
|
||
</section>
|
||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
|
||
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
|
||
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
|
||
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
|
||
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
|
||
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
|
||
</form>
|
||
<details class="save"><summary>Make this a reusable collection</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
|
||
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your collection library.</p>{:else}
|
||
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Collection name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save collection</Button></form>
|
||
{/if}
|
||
</details>
|
||
{:else if sourceDeploymentId}
|
||
<p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
|
||
{:else}
|
||
<form onsubmit={event => { event.preventDefault(); void review(); }}>
|
||
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
|
||
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
|
||
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
|
||
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
|
||
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
</dialog>
|
||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||
|
||
<style>
|
||
.key-ready { color: var(--state-online); font-weight: 600; }
|
||
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
|
||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
||
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||
.eyebrow { font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
|
||
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
|
||
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
|
||
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
|
||
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); }
|
||
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
|
||
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
|
||
.identity { padding-top: 20px; border-top: 1px solid var(--border-subtle); }.identity strong { font-family: var(--font-mono); font-size: 15px; overflow-wrap: anywhere; }.identity p { color: var(--text-secondary); margin-top: 6px; }
|
||
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: var(--radius-md); font-size: 13px; }.success { color: var(--accent-primary-hover); }
|
||
a, .text-button { color: var(--accent-primary-hover); }.text-button { background: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; }.save { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.save summary { cursor: pointer; font-size: 13px; }.save p, .save form { margin-top: 16px; }
|
||
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: var(--text-xs); } h2 { font-size: 22px; } }
|
||
</style>
|