Read-only script detail; move beta creation to /betas

The script detail page is now an informational card (target, scope, matched
servers, current + latest version) with edit-scope and upload-new-version
behind native <dialog> modals. The Open Beta panel is gone from the script
page entirely.

/betas gains an Open a beta section at the top. Its script dropdown filters
to entries that actually have something to test (unpromoted latest version,
no active beta), and pre-populates version + servers on script pick.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 14:40:56 -04:00
co-authored by Claude Opus 4.7
parent c78d0c62fd
commit 30e59c900c
2 changed files with 567 additions and 232 deletions
+285 -24
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
interface BetaListRow {
id: number;
@@ -16,17 +17,164 @@
servers: Array<{ server: string; stage: string; error: string | null }>;
}
interface ScriptListRow {
id: number;
name: string;
target_rel_path: string;
scope_type: 'game' | 'map' | null;
scope_value: string | null;
current_version_id: number | null;
latestVersion: {
id: number;
version: number;
sha256: string;
state: 'untested' | 'tested' | 'test-failed';
} | null;
runningBetaId: number | null;
}
interface Version {
id: number;
version: number;
sha256: string;
size: number;
state: 'untested' | 'tested' | 'test-failed';
}
interface ServerRow { name: string; game: string | null; map: string | null }
let betas = $state<BetaListRow[]>([]);
let loading = $state(true);
let scripts = $state<ScriptListRow[]>([]);
let fleetServers = $state<ServerRow[]>([]);
let pickedScriptId = $state<number | null>(null);
let pickedScriptDetail = $state<{
script: ScriptListRow;
versions: Version[];
matchedServers: string[];
} | null>(null);
let pickedVersionId = $state<number | null>(null);
let pickedServers = $state<Set<string>>(new Set());
let waitForHealth = $state(true);
let waitForMatch = $state(false);
let openingBeta = $state(false);
let flash = $state<string | null>(null);
async function load(): Promise<void> {
const res = await fetch('/api/betas');
const body = (await res.json()) as { betas: BetaListRow[] };
betas = body.betas;
const [betasRes, scriptsRes, serversRes] = await Promise.all([
fetch('/api/betas'),
fetch('/api/scripts'),
fetch('/api/servers')
]);
const bBody = (await betasRes.json()) as { betas: BetaListRow[] };
betas = bBody.betas;
if (scriptsRes.ok) {
const sBody = (await scriptsRes.json()) as { scripts: ScriptListRow[] };
scripts = sBody.scripts;
}
if (serversRes.ok) {
const svBody = (await serversRes.json()) as { servers: ServerRow[] };
fleetServers = svBody.servers;
}
loading = false;
}
onMount(load);
// A script is eligible for a new beta when it has an unpromoted version and
// isn't already in a beta. Fully-promoted scripts have nothing to test; an
// active beta blocks concurrent runs on the same script.
const eligibleScripts = $derived(
scripts.filter(
(s) =>
s.runningBetaId === null &&
s.latestVersion !== null &&
s.latestVersion.id !== s.current_version_id
)
);
async function onScriptChange(): Promise<void> {
pickedScriptDetail = null;
pickedVersionId = null;
pickedServers = new Set();
flash = null;
if (pickedScriptId === null) return;
const res = await fetch(`/api/scripts/${pickedScriptId}`);
if (!res.ok) { flash = 'failed to load script detail'; return; }
const body = (await res.json()) as {
script: ScriptListRow;
versions: Version[];
matchedServers: string[];
};
pickedScriptDetail = body;
// Default to the newest non-current version — the one the operator
// most likely wants to test.
const eligibleVersions = body.versions.filter((v) => v.id !== body.script.current_version_id);
pickedVersionId = eligibleVersions[0]?.id ?? null;
// Default test-server selection: matched-scope servers if scoped,
// whole fleet otherwise.
const defaults = body.script.scope_type ? body.matchedServers : fleetServers.map((s) => s.name);
pickedServers = new Set(defaults);
}
function toggleServer(name: string): void {
const next = new Set(pickedServers);
if (next.has(name)) next.delete(name);
else next.add(name);
pickedServers = next;
}
const eligibleVersions = $derived(
pickedScriptDetail
? pickedScriptDetail.versions.filter((v) => v.id !== pickedScriptDetail!.script.current_version_id)
: []
);
const candidateServers = $derived.by(() => {
if (!pickedScriptDetail) return [] as string[];
return pickedScriptDetail.script.scope_type
? pickedScriptDetail.matchedServers
: fleetServers.map((s) => s.name);
});
async function openNewBeta(e: Event): Promise<void> {
e.preventDefault();
if (!pickedScriptDetail || pickedVersionId === null || pickedServers.size === 0 || openingBeta) return;
openingBeta = true;
flash = null;
const s = pickedScriptDetail.script;
const triggerKind = !s.scope_type
? 'unscoped'
: s.current_version_id === null
? 'mark-required'
: 'version-update';
try {
const res = await fetch('/api/betas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scriptId: s.id,
scriptVersionId: pickedVersionId,
servers: [...pickedServers],
waitForHealth,
waitForMatch,
triggerKind
})
});
const body = (await res.json()) as {
ok: boolean;
error?: string;
result?: { betaId: number };
};
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
if (body.result) await goto(`/betas/${body.result.betaId}`);
} finally {
openingBeta = false;
}
}
const stateOrder = ['running', 'ready-promote', 'promoted', 'rolled-back'] as const;
let grouped = $derived(
stateOrder.map((state) => ({ state, rows: betas.filter((b) => b.state === state) }))
@@ -37,35 +185,148 @@
{#if loading}
<p class="empty">loading…</p>
{:else if betas.length === 0}
<p class="empty">no betas yet — open one from a script's page</p>
{:else}
{#each grouped as g (g.state)}
{#if g.rows.length > 0}
<section class="panel">
<h2>{g.state.replace('-', ' ')} · {g.rows.length}</h2>
<div class="rows">
{#each g.rows as b (b.id)}
<a class="row" href="/betas/{b.id}">
<span class="id">#{b.id}</span>
<span class="script">{b.script?.name ?? '(deleted)'}</span>
<span class="mono target">{b.script?.target_rel_path ?? ''}</span>
<span class="mono v">v{b.version?.version ?? '?'}</span>
<span class="tag state-{b.state}">{b.state}</span>
<span class="servers mono">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
</a>
{/each}
</div>
</section>
<section class="panel">
<h2>Open a beta</h2>
{#if eligibleScripts.length === 0}
<p class="empty">no scripts with untested versions — upload a new version from a script's page</p>
{:else}
<form onsubmit={openNewBeta} class="new-beta-form">
<label>
<span class="k">script</span>
<select bind:value={pickedScriptId} onchange={onScriptChange}>
<option value={null}>— pick a script —</option>
{#each eligibleScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
{#if pickedScriptDetail}
<label>
<span class="k">version</span>
<select bind:value={pickedVersionId}>
{#each eligibleVersions as v (v.id)}
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)} · {v.state}</option>
{/each}
</select>
</label>
<label>
<span class="k">test servers</span>
<div class="server-picker">
{#each candidateServers as name (name)}
<label class="chk">
<input
type="checkbox"
checked={pickedServers.has(name)}
onchange={() => toggleServer(name)}
/>
{name}
</label>
{/each}
{#if candidateServers.length === 0}
<span class="empty">no candidate servers</span>
{/if}
</div>
</label>
<div class="opts">
<label class="chk">
<input type="checkbox" bind:checked={waitForHealth} />
wait for server start healthy
</label>
<label class="chk">
<input type="checkbox" bind:checked={waitForMatch} />
wait for server match
</label>
</div>
<button
type="submit"
class="primary"
disabled={openingBeta || pickedVersionId === null || pickedServers.size === 0}
>
{openingBeta ? 'opening…' : 'open beta'}
</button>
{/if}
</form>
{#if flash}<p class="flash">{flash}</p>{/if}
{/if}
{/each}
</section>
{#if betas.length === 0}
<p class="empty">no betas yet — open one above</p>
{:else}
{#each grouped as g (g.state)}
{#if g.rows.length > 0}
<section class="panel">
<h2>{g.state.replace('-', ' ')} · {g.rows.length}</h2>
<div class="rows">
{#each g.rows as b (b.id)}
<a class="row" href="/betas/{b.id}">
<span class="id">#{b.id}</span>
<span class="script">{b.script?.name ?? '(deleted)'}</span>
<span class="mono target">{b.script?.target_rel_path ?? ''}</span>
<span class="mono v">v{b.version?.version ?? '?'}</span>
<span class="tag state-{b.state}">{b.state}</span>
<span class="servers mono">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
</a>
{/each}
</div>
</section>
{/if}
{/each}
{/if}
{/if}
<style>
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-3xl); letter-spacing: var(--tracking-display); margin: 0 0 28px; }
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-base); }
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-base); margin: 0; }
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
.new-beta-form { display: flex; flex-direction: column; gap: 14px; }
.new-beta-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
.new-beta-form .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
.new-beta-form select {
background: var(--bg-page);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.server-picker {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--r-sm);
}
.chk {
color: var(--ink-100);
font-family: var(--font-mono);
font-size: var(--text-mono);
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.opts { display: flex; gap: 24px; }
.primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
border: 1px solid var(--blue-400);
color: var(--cream);
border-radius: var(--r-sm);
padding: 12px 20px;
font-family: var(--font-body);
font-weight: 600;
cursor: pointer;
align-self: flex-start;
}
.primary:disabled { opacity: 0.4; cursor: default; }
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 12px 0 0; }
.rows { display: flex; flex-direction: column; gap: 6px; }
.row {
display: grid;
+282 -208
View File
@@ -28,37 +28,52 @@
let versions = $state<Version[]>([]);
let matchedServers = $state<string[]>([]);
let runningBetaId = $state<number | null>(null);
let servers = $state<ServerRow[]>([]);
let loading = $state(true);
let flash = $state<string | null>(null);
// Upload-new-version form.
let versionFile = $state<File | null>(null);
let versionNotes = $state('');
let uploadingVersion = $state(false);
// Scope form.
// Modal-form state (only used while the corresponding dialog is open).
let scopeType = $state<'none' | 'game' | 'map'>('none');
let scopeGame = $state('');
let scopeMap = $state('');
let updatingScope = $state(false);
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
let versionFile = $state<File | null>(null);
let versionNotes = $state('');
let uploadingVersion = $state(false);
// Dialog refs so we can call .showModal()/.close() imperatively.
let scopeDialog = $state<HTMLDialogElement | null>(null);
let uploadDialog = $state<HTMLDialogElement | null>(null);
// Kept around so we can reseed scopeGame after servers load (map→game backfill).
let fleetServers: ServerRow[] = [];
const editedScopeValue = $derived.by(() => {
if (scopeType === 'game') return scopeGame;
if (scopeType === 'map') return scopeMap;
return '';
});
// Open-beta form.
let betaVersionId = $state<number | null>(null);
let betaServers = $state<Set<string>>(new Set());
let waitForHealth = $state(true);
let waitForMatch = $state(false);
let openingBeta = $state(false);
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
// Info-panel derivations.
const currentVersion = $derived(
script?.current_version_id
? versions.find((v) => v.id === script!.current_version_id) ?? null
: null
);
const latestVersion = $derived(versions[0] ?? null);
const showLatestSeparately = $derived(
latestVersion !== null && currentVersion !== null && latestVersion.id !== currentVersion.id
);
function scopeLabel(s: Script): string {
if (!s.scope_type) return 'unscoped';
return `${s.scope_type}=${s.scope_value ?? '?'}`;
}
function stateClass(state: Version['state']): string {
return `tag state-${state}`;
}
async function load(): Promise<void> {
const [detailRes, serversRes, scopeRes] = await Promise.all([
fetch(`/api/scripts/${id}`),
@@ -76,26 +91,13 @@
versions = body.versions;
matchedServers = body.matchedServers;
runningBetaId = body.runningBetaId;
scopeType = script.scope_type ?? 'none';
// Seed the two selects from the current scope. For scope=map we need
// to figure out which game the map belongs to so the two selects show
// consistent state; use the first matched server as ground truth.
if (scopeType === 'game') {
scopeGame = script.scope_value ?? '';
scopeMap = '';
} else if (scopeType === 'map') {
scopeMap = script.scope_value ?? '';
scopeGame = '';
}
if (versions[0]) betaVersionId = versions[0].id;
seedScopeForm();
if (serversRes.ok) {
const s = (await serversRes.json()) as { servers: ServerRow[] };
servers = s.servers;
// Now that we know the fleet, backfill scopeGame for scope=map so
// the game select shows the right option.
fleetServers = s.servers;
if (scopeType === 'map' && scopeMap) {
const owner = servers.find((sv) => sv.map === scopeMap);
const owner = fleetServers.find((sv) => sv.map === scopeMap);
if (owner?.game) scopeGame = owner.game;
}
}
@@ -104,8 +106,57 @@
}
loading = false;
}
// Reset the scope modal fields to whatever the script currently has. Called
// on load and on cancel so the modal never carries stale edits into a
// second open.
function seedScopeForm(): void {
if (!script) return;
scopeType = script.scope_type ?? 'none';
if (scopeType === 'game') {
scopeGame = script.scope_value ?? '';
scopeMap = '';
} else if (scopeType === 'map') {
scopeMap = script.scope_value ?? '';
// Backfill game from the fleet if we already have it.
const owner = fleetServers.find((sv) => sv.map === scopeMap);
scopeGame = owner?.game ?? '';
} else {
scopeGame = '';
scopeMap = '';
}
}
onMount(load);
function openScopeModal(): void {
if (runningBetaId !== null) return;
seedScopeForm();
flash = null;
scopeDialog?.showModal();
}
function cancelScopeModal(): void {
seedScopeForm();
scopeDialog?.close();
}
function openUploadModal(): void {
if (runningBetaId !== null) return;
versionFile = null;
versionNotes = '';
flash = null;
uploadDialog?.showModal();
}
function cancelUploadModal(): void {
versionFile = null;
versionNotes = '';
uploadDialog?.close();
}
// Standard <dialog> idiom: click on the ::backdrop bubbles up as a click on
// the dialog element itself (target === the dialog), while clicks inside
// the content bubble up with target inside the dialog.
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
if (!dialog) return;
if (e.target === dialog) dialog.close();
}
function pickVersionFile(e: Event): void {
const t = e.target as HTMLInputElement;
versionFile = t.files?.[0] ?? null;
@@ -128,16 +179,15 @@
}
versionFile = null;
versionNotes = '';
if (body.betaRequired) {
flash = 'new version uploaded — open a beta below to deploy it';
}
uploadDialog?.close();
await load();
} finally {
uploadingVersion = false;
}
}
async function saveScope(): Promise<void> {
async function saveScope(e: Event): Promise<void> {
e.preventDefault();
if (updatingScope) return;
if (scopeType !== 'none' && !editedScopeValue) {
flash = `pick a ${scopeType}`;
@@ -159,63 +209,13 @@
flash = body.error ?? `HTTP ${res.status}`;
return;
}
scopeDialog?.close();
await load();
} finally {
updatingScope = false;
}
}
function toggleBetaServer(name: string): void {
const next = new Set(betaServers);
if (next.has(name)) next.delete(name);
else next.add(name);
betaServers = next;
}
function candidateServers(): string[] {
if (!script) return [];
if (!script.scope_type) return servers.map((s) => s.name);
return matchedServers;
}
async function openBeta(e: Event): Promise<void> {
e.preventDefault();
if (!script || betaVersionId === null || betaServers.size === 0 || openingBeta) return;
openingBeta = true;
flash = null;
const triggerKind = !script.scope_type
? 'unscoped'
: script.current_version_id === null
? 'mark-required'
: 'version-update';
try {
const res = await fetch('/api/betas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scriptId: script.id,
scriptVersionId: betaVersionId,
servers: [...betaServers],
waitForHealth,
waitForMatch,
triggerKind
})
});
const body = (await res.json()) as {
ok: boolean;
error?: string;
result?: { betaId: number };
};
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
if (body.result) await goto(`/betas/${body.result.betaId}`);
} finally {
openingBeta = false;
}
}
async function deleteScript(): Promise<void> {
if (!confirm('Delete this script?')) return;
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
@@ -238,110 +238,72 @@
<section class="panel banner">
<p>
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is currently running for this script.
Rollback or promote it before opening a new one.
Rollback or promote it before making changes.
</p>
</section>
{/if}
<section class="panel">
<h2>Scope</h2>
<div class="scope-form">
<select bind:value={scopeType} disabled={updatingScope}>
<option value="none">unscoped</option>
<option value="game">game</option>
<option value="map">map</option>
</select>
{#if scopeType === 'game'}
<select bind:value={scopeGame} disabled={updatingScope}>
<option value="">— pick a game —</option>
{#each scopeOptions.games as g (g)}
<option value={g}>{g}</option>
{/each}
</select>
{:else if scopeType === 'map'}
<select bind:value={scopeGame} disabled={updatingScope}>
<option value="">— pick a game —</option>
{#each scopeOptions.games as g (g)}
<option value={g}>{g}</option>
{/each}
</select>
<select bind:value={scopeMap} disabled={updatingScope || !scopeGame}>
<option value="">— pick a map —</option>
{#each (scopeOptions.mapsByGame[scopeGame] ?? []) as m (m)}
<option value={m}>{m}</option>
{/each}
</select>
{/if}
<button onclick={saveScope} disabled={updatingScope}>save scope</button>
</div>
{#if script.scope_type && matchedServers.length > 0}
<p class="hint mono">applies to: {matchedServers.join(', ')}</p>
{:else if script.scope_type}
<p class="hint mono">no servers currently match this scope</p>
{:else}
<p class="hint mono">unscoped — deploy to specific servers via a beta below</p>
{/if}
</section>
<section class="panel">
<h2>Upload new version</h2>
<form onsubmit={uploadVersion} class="version-form">
<input type="file" onchange={pickVersionFile} disabled={uploadingVersion} />
<input type="text" bind:value={versionNotes} placeholder="notes (optional)" disabled={uploadingVersion} />
<button type="submit" class="primary" disabled={uploadingVersion || !versionFile}>
{uploadingVersion ? 'uploading…' : 'upload version'}
</button>
</form>
</section>
{#if script.current_version_id === null && versions.length > 0}
<section class="panel banner-info">
<p>
<strong>This script has never been promoted.</strong>
Open a beta below to test v{versions[0].version} before it can be promoted to matching servers.
</p>
</section>
{/if}
<section class="panel">
<h2>Open beta with a version</h2>
{#if runningBetaId}
<p class="empty">a beta is already running for this script</p>
{:else if versions.length === 0}
<p class="empty">no versions yet — upload one above</p>
{:else}
<form onsubmit={openBeta} class="beta-form">
<label>
<span class="k">version</span>
<select bind:value={betaVersionId}>
{#each versions as v (v.id)}
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)}</option>
{/each}
</select>
</label>
<label>
<span class="k">test servers</span>
<div class="server-picker">
{#each candidateServers() as name (name)}
<label class="chk">
<input type="checkbox" checked={betaServers.has(name)} onchange={() => toggleBetaServer(name)} />
{name}
</label>
{/each}
{#if candidateServers().length === 0}
<span class="empty">no candidate servers</span>
{/if}
</div>
</label>
<div class="opts">
<label class="chk"><input type="checkbox" bind:checked={waitForHealth} /> wait for server start healthy</label>
<label class="chk"><input type="checkbox" bind:checked={waitForMatch} /> wait for server match</label>
<h2>Details</h2>
<div class="stats">
<div class="stat">
<div class="k">target file</div>
<div class="v mono">{script.target_rel_path}</div>
</div>
<div class="stat">
<div class="k">scope</div>
<div class="v mono">{scopeLabel(script)}</div>
</div>
<div class="stat">
<div class="k">applies to</div>
<div class="v mono">
{#if !script.scope_type}
<span class="dim">unscoped — deploy from a beta</span>
{:else if matchedServers.length === 0}
<span class="warn">no matching servers</span>
{:else}
{matchedServers.join(', ')}
{/if}
</div>
<button type="submit" class="primary" disabled={openingBeta || betaVersionId === null || betaServers.size === 0}>
{openingBeta ? 'opening…' : 'open beta'}
</button>
</form>
{/if}
</div>
<div class="stat">
<div class="k">current version</div>
<div class="v mono">
{#if currentVersion}
v{currentVersion.version} · {currentVersion.sha256.slice(0, 8)} <span class="tag cur">promoted</span>
{:else}
<span class="dim">— never promoted (open a beta from <a href="/betas">/betas</a>)</span>
{/if}
</div>
</div>
{#if showLatestSeparately && latestVersion}
<div class="stat">
<div class="k">latest version</div>
<div class="v mono">
v{latestVersion.version} · {latestVersion.sha256.slice(0, 8)}
<span class={stateClass(latestVersion.state)}>{latestVersion.state}</span>
</div>
</div>
{/if}
</div>
<div class="actions">
<button
class="secondary"
onclick={openScopeModal}
disabled={runningBetaId !== null}
title={runningBetaId !== null ? 'a beta is currently running' : ''}
>
edit scope
</button>
<button
class="secondary"
onclick={openUploadModal}
disabled={runningBetaId !== null}
title={runningBetaId !== null ? 'a beta is currently running' : ''}
>
upload new version
</button>
</div>
</section>
<section class="panel">
@@ -378,6 +340,64 @@
</section>
{#if flash}<p class="flash">{flash}</p>{/if}
<dialog bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
<form class="dlg" onsubmit={saveScope}>
<h3>Edit scope</h3>
<div class="scope-form">
<select bind:value={scopeType} disabled={updatingScope}>
<option value="none">unscoped</option>
<option value="game">game</option>
<option value="map">map</option>
</select>
{#if scopeType === 'game'}
<select bind:value={scopeGame} disabled={updatingScope}>
<option value="">— pick a game —</option>
{#each scopeOptions.games as g (g)}
<option value={g}>{g}</option>
{/each}
</select>
{:else if scopeType === 'map'}
<select bind:value={scopeGame} disabled={updatingScope}>
<option value="">— pick a game —</option>
{#each scopeOptions.games as g (g)}
<option value={g}>{g}</option>
{/each}
</select>
<select bind:value={scopeMap} disabled={updatingScope || !scopeGame}>
<option value="">— pick a map —</option>
{#each (scopeOptions.mapsByGame[scopeGame] ?? []) as m (m)}
<option value={m}>{m}</option>
{/each}
</select>
{/if}
</div>
<div class="dlg-actions">
<button type="button" class="secondary" onclick={cancelScopeModal} disabled={updatingScope}>cancel</button>
<button type="submit" class="primary" disabled={updatingScope}>
{updatingScope ? 'saving…' : 'save scope'}
</button>
</div>
{#if flash}<p class="flash">{flash}</p>{/if}
</form>
</dialog>
<dialog bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
<form class="dlg" onsubmit={uploadVersion}>
<h3>Upload new version</h3>
<div class="version-form">
<input type="file" onchange={pickVersionFile} disabled={uploadingVersion} />
<input type="text" bind:value={versionNotes} placeholder="notes (optional)" disabled={uploadingVersion} />
</div>
<div class="dlg-actions">
<button type="button" class="secondary" onclick={cancelUploadModal} disabled={uploadingVersion}>cancel</button>
<button type="submit" class="primary" disabled={uploadingVersion || !versionFile}>
{uploadingVersion ? 'uploading…' : 'upload version'}
</button>
</div>
{#if flash}<p class="flash">{flash}</p>{/if}
</form>
</dialog>
{/if}
<style>
@@ -393,8 +413,52 @@
.banner a { color: var(--amber-500); }
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
.scope-form { display: flex; gap: 10px; align-items: center; }
.scope-form select, .version-form input, .beta-form select {
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 12px 14px;
}
.stat .k {
color: var(--ink-500);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
}
.stat .v {
color: var(--ink-100);
margin-top: 6px;
font-size: var(--text-base);
word-break: break-word;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.dim { color: var(--ink-500); }
.warn { color: var(--amber-500); }
.actions { display: flex; gap: 10px; margin-top: 18px; }
button.secondary {
background: var(--bg-card);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 16px;
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
}
button.secondary:hover:not(:disabled) { border-color: var(--accent); box-shadow: var(--accent-glow); }
button.secondary:disabled { opacity: 0.4; cursor: default; }
.scope-form { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.scope-form select, .version-form input {
background: var(--bg-page);
color: var(--ink-100);
border: 1px solid var(--border);
@@ -403,29 +467,10 @@
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.scope-form button, .version-form .primary, .beta-form .primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
border: 1px solid var(--blue-400);
color: var(--cream);
border-radius: var(--r-sm);
padding: 10px 16px;
font-family: var(--font-body);
font-weight: 600;
cursor: pointer;
}
.scope-form button:disabled, .primary:disabled { opacity: 0.4; cursor: default; }
.hint { color: var(--ink-500); font-size: var(--text-mono-sm); margin: 12px 0 0; }
.version-form { display: flex; gap: 10px; align-items: center; }
.version-form input[type=text] { flex: 1; }
.beta-form { display: flex; flex-direction: column; gap: 14px; }
.beta-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
.beta-form .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
.server-picker { display: flex; flex-wrap: wrap; gap: 10px; padding: 12px; background: var(--bg-page); border: 1px solid var(--border); border-radius: var(--r-sm); }
.chk { color: var(--ink-100); font-family: var(--font-mono); font-size: var(--text-mono); display: flex; align-items: center; gap: 6px; cursor: pointer; }
.opts { display: flex; gap: 24px; }
.versions { display: flex; flex-direction: column; gap: 6px; }
.version {
display: grid;
@@ -448,10 +493,6 @@
.tag.state-tested { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.3); color: var(--spark-500); }
.tag.state-test-failed { background: rgba(255, 92, 92, 0.10); border-color: rgba(255, 92, 92, 0.4); color: var(--danger-500); }
.banner-info { background: rgba(124, 212, 255, 0.06); border-color: rgba(124, 212, 255, 0.28); }
.banner-info p { margin: 0; color: var(--ink-100); font-family: var(--font-body); font-size: var(--text-sm); }
.banner-info strong { color: var(--cream); }
.danger {
background: var(--bg-nav);
border: 1px solid rgba(255, 48, 80, 0.4);
@@ -464,4 +505,37 @@
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
.mono { font-family: var(--font-mono); }
dialog {
background: var(--bg-nav);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 0;
max-width: 560px;
width: calc(100vw - 40px);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.dlg { display: flex; flex-direction: column; gap: 14px; padding: var(--pad-panel); }
.dlg h3 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-300);
}
.dlg-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 6px; }
button.primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
border: 1px solid var(--blue-400);
color: var(--cream);
border-radius: var(--r-sm);
padding: 10px 16px;
font-family: var(--font-body);
font-weight: 600;
cursor: pointer;
}
button.primary:disabled { opacity: 0.4; cursor: default; }
</style>