119 lines
10 KiB
Svelte
119 lines
10 KiB
Svelte
<script lang="ts">
|
||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||
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));
|
||
const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug);
|
||
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 && !duplicate(row)));
|
||
let reason = $derived(!name.trim() ? 'Enter a collection name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–20 target lobbies for every row.' : '');
|
||
let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug))));
|
||
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.game, mapSlug: entry.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 collection.');
|
||
open = false; onsaved(body.blueprint, launch);
|
||
} catch (err) { error = (err as Error).message; }
|
||
finally { busy = false; }
|
||
}
|
||
</script>
|
||
|
||
<dialog bind:this={dialog} aria-label="Collection builder" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||
<div class="builder">
|
||
<header><div><span class="eyebrow">One collection. Your whole lineup.</span><h2>{blueprint ? 'Edit your collection' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection builder">×</button></header>
|
||
<p class="muted">Choose the total lobbies you want for each map. Existing and queued servers count; only missing lobbies are created. Mix Black Ops I and II in the same collection.</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>Collection 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" class:error={total > 100} role="status">{total} / 100 target lobbies</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}`} aria-invalid={duplicate(row) || undefined} aria-describedby={duplicate(row) ? `map-error-${row.key}` : undefined} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug} disabled={rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === map.slug)}>{map.friendly}</option>{/each}</select>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}>Already selected. Choose another map.</span>{/if}</label>
|
||
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} aria-invalid={!(Number.isInteger(row.count) && row.count >= 1 && row.count <= 20) || undefined} aria-describedby={`count-hint-${row.key}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /><span id={`count-hint-${row.key}`}>1–20</span></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 || !canAdd} onclick={addMap}>+ Add another map</Button>
|
||
</div>
|
||
<p class="muted">Collections save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. Before launch, you’ll review every new server ID, port, and key.</p>
|
||
{#if reason}<p class="validation" role="status">{reason}</p>{/if}
|
||
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save collection</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
</dialog>
|
||
|
||
<style>
|
||
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: 14px; line-height: 1.6; }
|
||
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: var(--radius-lg); }
|
||
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: var(--text-xs); 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-control); border-radius: var(--radius-md); 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: var(--radius-md); }.number { font-family: var(--font-mono); font-size: var(--text-xs); 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; }
|
||
header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; }
|
||
.close, .remove { min-width: 44px; }
|
||
.identity { grid-template-columns: 1fr; }
|
||
.map-row { grid-template-columns: minmax(0, 1fr) 44px; }
|
||
.number { display: none; }
|
||
.map-row label:first-of-type, .map-row label:nth-of-type(2) { grid-column: 1 / -1; }
|
||
.actions { align-items: stretch; flex-direction: column; }
|
||
}
|
||
</style>
|