Add deployment blueprints and reviewed launch recipes
This commit is contained in:
@@ -1,302 +1,154 @@
|
||||
<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';
|
||||
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
|
||||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', onblueprint }:
|
||||
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: 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 maps = $derived(catalogs[game]);
|
||||
let mapsError = $state<string | null>(null);
|
||||
let mapsLoaded = $state(false);
|
||||
|
||||
let gameMode = $state<'zombie'>('zombie');
|
||||
let mapSlug = $state<string>('');
|
||||
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
|
||||
let preview = $state<LaunchPreview | null>(null);
|
||||
let plutoKey = $state('');
|
||||
let savedIds = $state<string[]>([]);
|
||||
let deployedIds = $state<string[]>([]);
|
||||
let keysLoaded = $state(false);
|
||||
let deploying = $state(false);
|
||||
let flash = $state<string | null>(null);
|
||||
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;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/maps');
|
||||
if (!res.ok) throw new Error('Could not load map catalog');
|
||||
const body = (await res.json()) as { t5Zombie: ZombieMapEntry[]; t6Zombie: ZombieMapEntry[] };
|
||||
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
|
||||
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;
|
||||
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
|
||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||||
});
|
||||
|
||||
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;
|
||||
async function initialize() {
|
||||
const current = ++session;
|
||||
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
|
||||
blueprintName = ''; description = ''; savedName = ''; saving = false;
|
||||
blueprintId = initialBlueprintId;
|
||||
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);
|
||||
const fleet = await serversRes.json();
|
||||
deployedIds = [...fleet.servers.map((s: { name: string }) => s.name), ...(fleet.reservedNames ?? [])];
|
||||
keysLoaded = true;
|
||||
} catch (err) { flash = (err as Error).message; }
|
||||
}
|
||||
function close(): void { open = false; }
|
||||
|
||||
function onDialogClick(e: MouseEvent): void {
|
||||
if (e.target === dialog) close();
|
||||
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 blueprints. 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 blueprint is no longer available. Close this dialog and choose another starting point.');
|
||||
loaded = true;
|
||||
if (sourceDeploymentId) await review();
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
}
|
||||
|
||||
let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1)
|
||||
.find(n => !deployedIds.includes(`${game}_${gameMode}_${mapSlug}_${n}`)) ?? null);
|
||||
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
|
||||
let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `${game}_${gameMode}_${selectedMap.slug}_${lobby}` : '');
|
||||
|
||||
let hasSavedKey = $derived(savedIds.includes(previewName));
|
||||
$effect(() => { game; mapSlug; lobby; plutoKey = ''; });
|
||||
$effect(() => {
|
||||
if (maps.length && !maps.some((entry) => entry.slug === mapSlug)) mapSlug = maps[0].slug;
|
||||
});
|
||||
|
||||
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 || !keysLoaded || lobby === null) 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;
|
||||
async function review() {
|
||||
if (busy) return;
|
||||
const current = session;
|
||||
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
|
||||
try {
|
||||
const res = await fetch('/api/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ game, gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined })
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; deploymentId?: string; error?: string; kind?: string };
|
||||
if (!res.ok || !body.ok || !body.deploymentId) {
|
||||
flash = body.error ?? 'Deploy failed';
|
||||
if (body.kind === 'no-slot') await loadKeys();
|
||||
deploying = false;
|
||||
return;
|
||||
}
|
||||
close();
|
||||
const res = await fetch('/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
||||
if (current === session) preview = body;
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
finally { if (current === session) busy = false; }
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
if (!preview || !preview.ready || 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(`/deployments/${encodeURIComponent(body.deploymentId)}`);
|
||||
} catch (err) {
|
||||
flash = (err as Error).message;
|
||||
deploying = false;
|
||||
}
|
||||
} 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 blueprint.');
|
||||
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} 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>
|
||||
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (event.target === dialog && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="composer">
|
||||
<header><div><span class="eyebrow">{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}</span><h2>{preview ? 'Review your next lobby' : 'Create your next server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</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 !loaded}
|
||||
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p>
|
||||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{:else if preview}
|
||||
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {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>
|
||||
<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key ready — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
|
||||
<p class="muted">{preview.hasSavedKey ? 'The key saved for this exact lobby will be reused. Enter a replacement to update it.' : 'Your key is saved for this exact lobby when you launch, including if provisioning fails.'} <a href="/server-keys" onclick={() => open = false}>Manage keys</a></p>
|
||||
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||||
<p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
|
||||
</form>
|
||||
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this map, image reference, and managed files for another launch. Each launch gets a new lobby, port, and key.</p>
|
||||
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your blueprint library.</p>{:else}
|
||||
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Blueprint 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 blueprint</Button></form>
|
||||
{/if}
|
||||
</details>
|
||||
{:else if sourceDeploymentId}
|
||||
<p class="muted">{busy ? 'Preparing the original launch recipe…' : 'Review the original recipe with a fresh lobby and port.'}</p><Button disabled={busy} onclick={review}>Review launch</Button>
|
||||
{:else}
|
||||
<div class="fields">
|
||||
<Field label="Game">
|
||||
<Select bind:value={game} options={[{ value: 't5', label: 'Black Ops 1 (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }]} disabled={deploying} />
|
||||
</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="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">{!keysLoaded ? "Loading available lobby..." : lobby === null ? "All lobby slots for this map are in use" : "Lowest available 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 || lobby === null}
|
||||
loading={deploying}
|
||||
>
|
||||
Deploy server
|
||||
</Button>
|
||||
</div>
|
||||
<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.recipe.mapName}</option>{/each}</select></label>
|
||||
{#if selectedBlueprint}<section class="recipe-panel"><LaunchRecipe recipe={selectedBlueprint.recipe} />{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
|
||||
{:else}<p class="muted">Pick a map. We’ll gather its promoted scripts, plugins, and configuration into a recipe you can review and save.</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 exact managed files, 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)}>Preview launch →</Button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
|
||||
</form>
|
||||
</div>
|
||||
</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(4) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 18px; 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: 10px; 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-strong); border-radius: 8px; 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: 11px; }
|
||||
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; 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: 8px; 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: 10px; } h2 { font-size: 22px; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
|
||||
let { recipe }: { recipe: LaunchRecipeSummary } = $props();
|
||||
</script>
|
||||
|
||||
<div class="recipe">
|
||||
<div class="recipe-heading"><div><span class="eyebrow">Launch recipe</span><h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
|
||||
<dl>
|
||||
<div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div>
|
||||
<div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div>
|
||||
</dl>
|
||||
<details>
|
||||
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} in this recipe</summary>
|
||||
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
|
||||
<p>Captured {new Date(recipe.capturedAt).toLocaleString()}. Managed file contents are saved with this recipe. Image tags and Plutainer defaults can change; use an image digest and a promoted baseline for more reproducible launches.</p>
|
||||
<p>Future fleet promotions and drift repair still follow the server’s game and map.</p>
|
||||
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recipe { display: grid; gap: 16px; min-width: 0; }
|
||||
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
h3 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
|
||||
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: 12px; white-space: nowrap; }
|
||||
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: 12px; } dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.mono, code, .fingerprint { font-family: var(--font-mono); }
|
||||
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: 13px; }
|
||||
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: 11px; } li span, small { color: var(--text-secondary); }
|
||||
p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user