Add multi-server blueprint collections and atomic launches
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import Button from './primitives/Button.svelte';
|
||||
import type { BlueprintSummary } from '$lib/deployment-plan.js';
|
||||
import type { ZombieGame, ZombieMapEntry } from '$lib/map-aliases.js';
|
||||
|
||||
let { open = $bindable(false), blueprint = null, onsaved }:
|
||||
{ open?: boolean; blueprint?: BlueprintSummary | null; onsaved: (blueprint: BlueprintSummary, launch: boolean) => void } = $props();
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let rows = $state<{ key: number; game: ZombieGame; mapSlug: string; count: number }[]>([]);
|
||||
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
||||
let loaded = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
|
||||
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20));
|
||||
let wasOpen = false;
|
||||
let session = 0;
|
||||
let nextKey = 0;
|
||||
|
||||
$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;
|
||||
name = blueprint?.name ?? ''; description = blueprint?.description ?? '';
|
||||
rows = blueprint?.entries.map(entry => ({ key: nextKey++, game: entry.recipe.game, mapSlug: entry.recipe.mapSlug, count: entry.count })) ?? [];
|
||||
error = ''; loaded = false; busy = false;
|
||||
try {
|
||||
const res = await fetch('/api/maps');
|
||||
if (!res.ok) throw new Error('Could not load maps. Try again.');
|
||||
const body = await res.json();
|
||||
if (current !== session) return;
|
||||
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
|
||||
if (!rows.length) addMap();
|
||||
loaded = true;
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
}
|
||||
|
||||
function addMap() {
|
||||
for (const game of ['t5', 't6'] as const) {
|
||||
const map = catalogs[game].find(map => !rows.some(row => row.game === game && row.mapSlug === map.slug));
|
||||
if (map) { rows = [...rows, { key: nextKey++, game, mapSlug: map.slug, count: 1 }]; return; }
|
||||
}
|
||||
}
|
||||
|
||||
async function save(launch: boolean) {
|
||||
if (busy || !valid) return;
|
||||
busy = true; error = '';
|
||||
try {
|
||||
const res = await fetch(blueprint ? `/api/blueprints/${encodeURIComponent(blueprint.id)}` : '/api/blueprints', {
|
||||
method: blueprint ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count })) }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not save blueprint.');
|
||||
open = false; onsaved(body.blueprint, launch);
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="builder">
|
||||
<header><div><span class="eyebrow">One blueprint. Your whole lineup.</span><h2>{blueprint ? 'Edit your blueprint' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close blueprint builder">×</button></header>
|
||||
<p class="muted">Choose your maps and how many lobbies to launch for each. Mix Black Ops I and II in the same blueprint.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !loaded}<p class="muted">Loading maps…</p>{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{:else}
|
||||
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
|
||||
<div class="identity"><label>Blueprint name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
|
||||
<div class="lineup">
|
||||
<div class="section-title"><h3>Your lineup</h3><span class="count">{total} server{total === 1 ? '' : 's'}</span></div>
|
||||
{#each rows as row, index (row.key)}
|
||||
<div class="map-row">
|
||||
<span class="number">{String(index + 1).padStart(2, '0')}</span>
|
||||
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label>Map<select aria-label={`Map ${index + 1}`} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
|
||||
<label>Lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
|
||||
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button>
|
||||
</div>
|
||||
<p class="muted">{blueprint ? 'Changing lobby counts keeps the saved files for each map. Newly added maps use their current promoted setup.' : 'Each map saves its current promoted setup. Before launch, you’ll review every new server ID, port, and key.'}</p>
|
||||
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview {total} server{total === 1 ? '' : 's'} →</Button></div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 16px; }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
|
||||
.eyebrow { display: block; color: var(--text-secondary); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: 10px; }.number { font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
|
||||
@media (max-width: 620px) { .builder { padding: 20px; }.identity { grid-template-columns: 1fr; }.map-row { grid-template-columns: 1fr 85px 25px; }.number { display: none; }.map-row label:first-of-type { grid-column: 1 / -1; }.actions { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user