Unify UI workflows and streamline fleet management
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { fleetServers as sharedFleet } from '$ui/stores/fleet.js';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import { betaSelectionHref, mapLabel } from '$lib/script-library.js';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import PluginGithubImport from './PluginGithubImport.svelte';
|
||||
import ConfigEditor from './ConfigEditor.svelte';
|
||||
@@ -60,9 +67,10 @@
|
||||
|
||||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||||
let uploadDialog = $state<HTMLDialogElement | null>(null);
|
||||
let fleetServers: ServerRow[] = [];
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
const fleetServers = $derived($sharedFleet);
|
||||
let loadError = $state('');
|
||||
let limit = $state(25);
|
||||
let request = 0;
|
||||
|
||||
const editedScopeValue = $derived.by(() => {
|
||||
if (scopeType === 'game') return scopeGame;
|
||||
@@ -82,39 +90,27 @@
|
||||
|
||||
function scopeLabel(s: Artifact): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
|
||||
}
|
||||
function stateVariant(s: Version['state']): 'online' | 'danger' | 'neutral' {
|
||||
return s === 'tested' ? 'online' :
|
||||
s === 'test-failed' ? 'danger' :
|
||||
'neutral';
|
||||
return s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : s.scope_value === 't5' ? 'Black Ops I' : s.scope_value === 't6' ? 'Black Ops II' : s.scope_value ?? 'Unscoped';
|
||||
}
|
||||
|
||||
async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> {
|
||||
const [detailRes, serversRes, scopeRes] = await Promise.all([
|
||||
fetch(`/api/scripts/${id}`),
|
||||
fetch('/api/servers'),
|
||||
fetch('/api/scope-options')
|
||||
]);
|
||||
if (!detailRes.ok) { loading = false; return; }
|
||||
const body = (await detailRes.json()) as {
|
||||
script: Artifact; versions: Version[];
|
||||
matchedServers: string[]; runningBetaId: number | null;
|
||||
};
|
||||
artifact = body.script;
|
||||
versions = body.versions;
|
||||
matchedServers = body.matchedServers;
|
||||
runningBetaId = body.runningBetaId;
|
||||
|
||||
if (serversRes.ok) {
|
||||
const s = (await serversRes.json()) as { servers: ServerRow[] };
|
||||
fleetServers = s.servers;
|
||||
}
|
||||
if (scopeRes.ok) {
|
||||
scopeOptions = (await scopeRes.json()) as ScopeOptions;
|
||||
}
|
||||
if (seedForm) seedScopeForm();
|
||||
loading = false;
|
||||
const requestedId = id;
|
||||
const current = ++request;
|
||||
try {
|
||||
const [detailRes, scopeRes] = await Promise.all([
|
||||
fetch('/api/scripts/' + requestedId),
|
||||
seedForm ? fetch('/api/scope-options') : Promise.resolve(null)
|
||||
]);
|
||||
if (!detailRes.ok) throw new Error(detailRes.status === 404 ? 'This file no longer exists.' : 'Could not refresh file details.');
|
||||
const body = await detailRes.json();
|
||||
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
|
||||
if (requestedId !== id || current !== request) return;
|
||||
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId;
|
||||
if (scopes) scopeOptions = scopes;
|
||||
if (seedForm) seedScopeForm();
|
||||
loadError = '';
|
||||
} catch (error) { if (requestedId === id && current === request) loadError = (error as Error).message; }
|
||||
finally { if (requestedId === id && current === request) loading = false; }
|
||||
}
|
||||
|
||||
function seedScopeForm(): void {
|
||||
@@ -126,17 +122,14 @@
|
||||
} else if (scopeType === 'map') {
|
||||
scopeMap = artifact.scope_value ?? '';
|
||||
const owner = fleetServers.find((sv) => sv.map === scopeMap);
|
||||
scopeGame = owner?.game ?? '';
|
||||
scopeGame = owner?.game ?? Object.keys(scopeOptions.mapsByGame).find(game => scopeOptions.mapsByGame[game].includes(scopeMap)) ?? '';
|
||||
} else {
|
||||
scopeGame = ''; scopeMap = '';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
poll = setInterval(() => load({ seedForm: false }), 5000);
|
||||
});
|
||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||
$effect(() => { id; loading = true; artifact = null; limit = 25; void load(); });
|
||||
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
|
||||
|
||||
function openScopeModal(): void {
|
||||
if (runningBetaId !== null) return;
|
||||
@@ -163,7 +156,10 @@
|
||||
}
|
||||
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
|
||||
if (!dialog) return;
|
||||
if (e.target === dialog) dialog.close();
|
||||
if (e.target === dialog && !uploadingVersion && !updatingScope) {
|
||||
const bounds = dialog.getBoundingClientRect();
|
||||
if (e.clientX < bounds.left || e.clientX > bounds.right || e.clientY < bounds.top || e.clientY > bounds.bottom) dialog.close();
|
||||
}
|
||||
}
|
||||
|
||||
function pickVersionFile(e: Event): void {
|
||||
@@ -189,7 +185,7 @@
|
||||
versionFile = null; versionNotes = '';
|
||||
uploadDialog?.close();
|
||||
await load();
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
uploadingVersion = false;
|
||||
}
|
||||
}
|
||||
@@ -218,17 +214,19 @@
|
||||
}
|
||||
scopeDialog?.close();
|
||||
await load();
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
updatingScope = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteArtifact(): Promise<void> {
|
||||
if (!confirm(`Delete this ${family}?`)) return;
|
||||
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'Delete failed'; return; }
|
||||
goto(basePath);
|
||||
if (!await confirmAction({title:'Delete ' + family + '?',message:'Delete ' + (artifact?.name ?? 'this file') + ' and its saved versions?', confirmLabel:'Delete', danger:true})) return;
|
||||
try {
|
||||
const response = await fetch('/api/scripts/' + id, {method:'DELETE'});
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not delete file.');
|
||||
await goto(basePath);
|
||||
} catch (error) { flash = (error as Error).message; }
|
||||
}
|
||||
|
||||
let gameOptions = $derived([
|
||||
@@ -246,11 +244,12 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if !artifact}
|
||||
{:else if !artifact && !loadError}
|
||||
<EmptyState title="Not found" description="This {family} may have been deleted." />
|
||||
{:else}
|
||||
{:else if artifact}
|
||||
{@const a = artifact}
|
||||
<div class="page">
|
||||
<SectionHeader title={a.name} subtitle={a.target_rel_path}>
|
||||
@@ -303,7 +302,7 @@
|
||||
{:else if matchedServers.length === 0}
|
||||
<span class="warn">No matching servers</span>
|
||||
{:else}
|
||||
{matchedServers.join(', ')}
|
||||
{#each matchedServers as name}<a class="matched-server" href={"/servers/" + encodeURIComponent(name)}><ServerIdentity {name} /></a>{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -323,7 +322,7 @@
|
||||
<span class="k">Latest version</span>
|
||||
<span class="v mono">
|
||||
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
|
||||
<Badge variant={stateVariant(latestVersion.state)} size="sm">{latestVersion.state}</Badge>
|
||||
<StatusBadge state={latestVersion.state} size="sm" />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -344,7 +343,7 @@
|
||||
<EmptyState title="No versions yet" description="Upload a first version to get started." />
|
||||
{:else}
|
||||
<div class="version-list">
|
||||
{#each versions as v (v.id)}
|
||||
{#each versions.slice(0,limit) as v (v.id)}
|
||||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
|
||||
@@ -352,16 +351,18 @@
|
||||
<span class="v-notes">{v.notes ?? ''}
|
||||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||||
</span>
|
||||
<span class="v-when">{v.uploaded_at}</span>
|
||||
<time class="v-when" datetime={v.uploaded_at}>{new Date(v.uploaded_at).toLocaleString()}</time>
|
||||
{#if a.current_version_id === v.id}
|
||||
<Badge variant="online" size="sm">Promoted</Badge>
|
||||
{:else}
|
||||
<Badge variant={stateVariant(v.state)} size="sm">{v.state.replace('-', ' ')}</Badge>
|
||||
<StatusBadge state={v.state} size="sm" />
|
||||
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ListFooter total={versions.length} bind:limit label="versions" />
|
||||
</Card>
|
||||
|
||||
{#if family === 'plugin'}
|
||||
@@ -376,20 +377,20 @@
|
||||
{#if flash}<div class="alert">{flash}</div>{/if}
|
||||
|
||||
<!-- Scope dialog -->
|
||||
<dialog bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<dialog aria-label="Edit scope" oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<form class="dlg" onsubmit={saveScope}>
|
||||
<header class="dlg-head">
|
||||
<h3>Edit scope</h3>
|
||||
<button type="button" class="close" onclick={cancelScopeModal} aria-label="Close">×</button>
|
||||
<button type="button" class="close" disabled={updatingScope} onclick={cancelScopeModal} aria-label="Close">×</button>
|
||||
</header>
|
||||
<Field label="Scope">
|
||||
<div class="scope-row">
|
||||
<Select bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||||
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||||
{#if scopeType === 'game' || scopeType === 'map'}
|
||||
<Select bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
|
||||
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
|
||||
{/if}
|
||||
{#if scopeType === 'map'}
|
||||
<Select bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||||
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
@@ -404,11 +405,11 @@
|
||||
</dialog>
|
||||
|
||||
<!-- Upload dialog -->
|
||||
<dialog bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
|
||||
<dialog aria-label="Upload new version" oncancel={event => { if (uploadingVersion) event.preventDefault(); }} bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
|
||||
<form class="dlg" onsubmit={uploadVersion}>
|
||||
<header class="dlg-head">
|
||||
<h3>Upload new version</h3>
|
||||
<button type="button" class="close" onclick={cancelUploadModal} aria-label="Close">×</button>
|
||||
<button type="button" class="close" disabled={uploadingVersion} onclick={cancelUploadModal} aria-label="Close">×</button>
|
||||
</header>
|
||||
<Field label="File">
|
||||
<Input type="file" accept={family === 'plugin' ? '.dll' : undefined} onchange={pickVersionFile} disabled={uploadingVersion} />
|
||||
@@ -589,4 +590,5 @@
|
||||
.panel-head, .head-actions, .dlg-actions { flex-wrap: wrap; }
|
||||
.dlg { padding: var(--space-4); }
|
||||
}
|
||||
.matched-server { display:block; padding:5px 0; color:var(--text-primary); text-decoration:none; font-family:var(--font-body); }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
||||
import { coverageSummary, needsSessionTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||
let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props();
|
||||
@@ -11,6 +12,7 @@
|
||||
let saving = $state(false);
|
||||
let editingId = $state('');
|
||||
let appliedPresetName = $state('');
|
||||
let choosingPreset = $state(false);
|
||||
const matchingPreset = $derived(presets.find(p => p.name === appliedPresetName && JSON.stringify(p.plan) === JSON.stringify(plan))
|
||||
?? presets.find(p => JSON.stringify(p.plan) === JSON.stringify(plan)));
|
||||
const savedPresets = $derived(presets.filter(p => !builtInPlans.some(b => b.name === p.name)));
|
||||
@@ -32,6 +34,7 @@
|
||||
// Validation returns an independent plain object, even when the source is a Svelte proxy.
|
||||
plan = parseBetaPlan(preset.plan);
|
||||
appliedPresetName = name;
|
||||
choosingPreset = false;
|
||||
editingId = ''; error = '';
|
||||
message = `${name} applied. ${plan.requirements.length} requirement${plan.requirements.length === 1 ? '' : 's'} loaded.`;
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
@@ -61,8 +64,9 @@
|
||||
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
|
||||
<span class="pill">Manual promotion</span>
|
||||
</header>
|
||||
<p>Choose a starting point, then tailor what each server needs to prove.</p>
|
||||
<div class="section-heading"><p>Tailor what each server needs to prove.</p><Button size="sm" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</Button></div>
|
||||
|
||||
{#if choosingPreset}
|
||||
<div class="presets" role="group" aria-label="Start from a preset">
|
||||
{#each builtInPlans as preset, index (preset.name)}
|
||||
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
|
||||
@@ -83,7 +87,8 @@
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="feedback" role="status" aria-live="polite">{message}</div>
|
||||
{/if}
|
||||
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="active-plan">
|
||||
@@ -194,17 +199,17 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.builder { display: grid; gap: 16px; padding-top: 24px; border-top: 1px solid var(--border-strong); }
|
||||
.builder { display: grid; gap: 16px; }
|
||||
h3, h4, h5, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
|
||||
h4 { font-size: 19px; font-weight: 600; }
|
||||
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.section-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: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.pill { padding: 5px 9px; border-radius: 99px; font-size: 11px; color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
|
||||
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.pill { padding: 5px 9px; border-radius: 99px; font-size: var(--text-xs); color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
|
||||
.presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 4px; }
|
||||
.preset { display: flex; flex-direction: column; text-align: left; padding: 18px; border-radius: 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); gap: 10px; transition: border-color .15s, background-color .15s; }
|
||||
.preset { display: flex; flex-direction: column; text-align: left; padding: 18px; border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-strong); gap: 10px; transition: border-color .15s, background-color .15s; }
|
||||
.preset:hover { border-color: var(--accent-primary-hover); }
|
||||
.preset.selected { border-color: var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-sunken)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 30%, transparent); }
|
||||
.preset-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
@@ -216,27 +221,27 @@
|
||||
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: 12px; line-height: 1.65; flex: 1; }
|
||||
.preset li + li { margin-top: 5px; }
|
||||
.preset li::marker { color: var(--accent-primary-hover); }
|
||||
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: 11px; color: var(--text-secondary); }
|
||||
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.preset-footer > span { color: var(--accent-primary-hover); }
|
||||
.feedback { color: var(--accent-primary-hover); font-size: 12px; min-height: 18px; }
|
||||
.error { color: var(--state-danger); }
|
||||
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: 12px; }
|
||||
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
|
||||
.plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; padding: 16px 0; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); }
|
||||
dt { color: var(--text-secondary); font-size: 11px; margin-bottom: 6px; }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); margin-bottom: 6px; }
|
||||
dd { font-size: 13px; font-weight: 550; line-height: 1.5; }
|
||||
dd small { font-size: 11px; font-weight: 400; color: var(--text-secondary); }
|
||||
dd small { font-size: var(--text-xs); font-weight: 400; color: var(--text-secondary); }
|
||||
.plan-caption { font-size: 12px; }
|
||||
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: 12px; }
|
||||
.telemetry-note span { color: var(--accent-primary-hover); margin-right: 5px; }
|
||||
.requirements { display: grid; gap: 10px; }
|
||||
.requirement { border: 1px solid var(--border-subtle); border-radius: 9px; background: var(--surface-sunken); min-width: 0; overflow: hidden; }
|
||||
.requirement { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); min-width: 0; overflow: hidden; }
|
||||
.requirement-heading { display: flex; align-items: center; gap: 14px; padding: 16px; }
|
||||
.requirement-copy { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.requirement-copy .eyebrow { font-size: 9px; margin-bottom: 4px; }
|
||||
.requirement-copy .eyebrow { font-size: var(--text-xs); margin-bottom: 4px; }
|
||||
.remove { padding: 3px 8px; font-size: 21px; background: transparent; border: 0; color: var(--text-secondary); }
|
||||
.remove:hover { color: var(--state-danger); }
|
||||
.editor { border-top: 1px solid var(--border-subtle); }
|
||||
.editor > summary { padding: 10px 16px; font-size: 11px; color: var(--accent-primary-hover); }
|
||||
.editor > summary { padding: 10px 16px; font-size: var(--text-xs); color: var(--accent-primary-hover); }
|
||||
.editor > summary > span { color: var(--text-secondary); margin-left: 10px; }
|
||||
.editor-fields { display: grid; gap: 12px; padding: 6px 16px 16px; }
|
||||
.fields, .add-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
|
||||
@@ -250,14 +255,14 @@
|
||||
.secondary { background: var(--surface-raised); font-size: 12px; min-height: 40px; }
|
||||
.secondary:hover:not(:disabled) { border-color: var(--accent-primary-hover); }
|
||||
.policy-settings { display: grid; gap: 8px; }
|
||||
.policy { border: 1px solid var(--border-subtle); border-radius: 9px; }
|
||||
.policy { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: 13px; font-weight: 550; }
|
||||
.policy small { display: block; font-size: 11px; color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
|
||||
.edit-hint { color: var(--accent-primary-hover); font-size: 11px; }
|
||||
.policy small { display: block; font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
|
||||
.edit-hint { color: var(--accent-primary-hover); font-size: var(--text-xs); }
|
||||
.policy-body { padding: 0 18px 18px; display: grid; gap: 12px; }
|
||||
.policy-body.fields { display: flex; }
|
||||
summary { cursor: pointer; }
|
||||
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: 8px; }
|
||||
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); }
|
||||
.empty-plan strong { display: block; margin-bottom: 8px; font-size: 14px; }
|
||||
@media (max-width: 850px) { .presets { grid-template-columns: 1fr; } .preset { padding: 16px; } .plan-facts { grid-template-columns: 1fr; gap: 12px; } }
|
||||
@media (max-width: 480px) { .active-plan { padding: 14px; } .requirement-heading { padding: 12px; gap: 10px; } .editor > summary > span { display: block; margin: 5px 0 0; } .add-row { align-items: stretch; } .add-row button { width: 100%; } }
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { coverageSummary } from '$lib/beta-plan-summary.js';
|
||||
import type { BetaTestView } from '$lib/beta-plan.js';
|
||||
let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props();
|
||||
let notes = $state<Record<string, string>>({});
|
||||
let evidenceOpen = $state(false);
|
||||
let evidenceLimit = $state(25);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
async function action(action: string, extra: Record<string, string> = {}) {
|
||||
@@ -17,23 +24,22 @@
|
||||
</script>
|
||||
|
||||
<section aria-label="Test plan progress">
|
||||
<div class="heading"><h2>Test plan</h2><strong class:failed={['failed', 'inconclusive'].includes(test.outcome)}>{test.outcome}</strong></div>
|
||||
<div class="heading"><h2>Test plan</h2><StatusBadge state={test.outcome} /></div>
|
||||
{#if test.reason}<p>{test.reason}</p>{/if}
|
||||
<p>Coverage: {test.plan.scope === 'quorum' ? `${test.plan.quorum} servers` : test.plan.scope}. Window: {test.plan.timeoutHours} hours.
|
||||
{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All servers deployed together.'}
|
||||
Failure action: {test.plan.failureAction}. Rollback restart: {test.plan.rollbackRestart}.</p>
|
||||
<p>{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion</p>
|
||||
<details><summary>Test policy</summary><p>{test.plan.waveSize ? test.plan.waveSize + ' servers per wave.' : 'All servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause the test for investigation.' : 'Failed checks hold the test for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; servers must be restarted manually.'}</p></details>
|
||||
{#if !closed}
|
||||
<div class="actions">
|
||||
{#if test.outcome === 'paused'}<button disabled={busy} onclick={() => action('resume')}>Resume</button>{/if}
|
||||
{#if ['active', 'passed'].includes(test.outcome)}<button disabled={busy} onclick={() => action('pause')}>Pause test</button>{/if}
|
||||
{#if ['paused', 'failed'].includes(test.outcome)}<button disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</button>{/if}
|
||||
{#if test.outcome === 'paused'}<Button size="sm" disabled={busy} onclick={() => action('resume')}>Resume</Button>{/if}
|
||||
{#if ['active', 'passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('pause')}>Pause test</Button>{/if}
|
||||
{#if ['paused', 'failed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</Button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}<p role="alert" class="failed">{error}</p>{/if}
|
||||
{#if test.servers.length === 0}<p>Waiting for the first server restart. Evidence collection begins with that run.</p>{/if}
|
||||
{#each test.servers as server (server.server)}
|
||||
<article>
|
||||
<h3>{server.server} <small>Recovery cycles: {server.phase}</small></h3>
|
||||
<h3><ServerIdentity name={server.server} /> <small>Recovery cycles: {server.phase}</small></h3>
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
|
||||
<div class="requirement" class:failed={progress.failed}>
|
||||
@@ -47,19 +53,22 @@
|
||||
{#if requirement.kind === 'manual' && !progress.passed && !closed}
|
||||
<div class="actions">
|
||||
<input aria-label={`Tester and observation for ${progress.label} on ${server.server}`} placeholder="Tester name and observation" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} />
|
||||
<button disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</button>
|
||||
<Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
{/each}
|
||||
<details>
|
||||
<details ontoggle={event => evidenceOpen = event.currentTarget.open}>
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''})</summary>
|
||||
{#each test.evidence as event (event.id)}
|
||||
{#if evidenceOpen}
|
||||
{#each test.evidence.slice(0,evidenceLimit) as event (event.id)}
|
||||
<div class="evidence"><strong>{event.server} · {event.kind}</strong><time>{new Date(event.received_at).toLocaleString()}</time><pre>{event.payload}</pre></div>
|
||||
{/each}
|
||||
<ListFooter total={test.evidence.length} bind:limit={evidenceLimit} label="events" />
|
||||
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
|
||||
{/if}
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -70,12 +79,12 @@
|
||||
p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.heading, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.heading { justify-content: space-between; }
|
||||
article { border: 1px solid var(--border-subtle); border-radius: 8px; padding: 16px; }
|
||||
article { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||
.requirement { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||
progress { width: 100%; height: 8px; accent-color: var(--state-online); }
|
||||
input, button { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||||
input { flex: 1; min-width: 160px; } button, summary { cursor: pointer; } button:disabled { opacity: .5; }
|
||||
input { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||||
input { flex: 1; min-width: 160px; } summary { cursor: pointer; }
|
||||
.failed { color: var(--state-danger); }
|
||||
.evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<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';
|
||||
@@ -63,7 +64,7 @@
|
||||
}
|
||||
</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}>
|
||||
<dialog bind:this={dialog} aria-label="Blueprint 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 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 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 blueprint.</p>
|
||||
@@ -93,12 +94,12 @@
|
||||
</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 { 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: 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; }
|
||||
.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-strong); 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; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -30,7 +31,16 @@
|
||||
if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
|
||||
} catch { /* Draft storage is optional. */ }
|
||||
});
|
||||
beforeNavigate(({ cancel }) => { if (dirty && !confirm('Leave this editor? Your draft is kept in this browser when storage is available.')) cancel(); });
|
||||
let allowNavigation = false;
|
||||
beforeNavigate(({ cancel, to, type }) => {
|
||||
if (!dirty || allowNavigation || type === 'leave' || !to) return;
|
||||
cancel();
|
||||
void confirmAction({title:'Leave this editor?', message:'You have unsaved changes. A draft is kept in this browser when storage is available.', confirmLabel:'Leave editor'}).then(async accepted => {
|
||||
if (!accepted) return;
|
||||
allowNavigation = true;
|
||||
try { await goto(to.url); } finally { allowNavigation = false; }
|
||||
});
|
||||
});
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
|
||||
$effect(() => {
|
||||
const version = selected;
|
||||
@@ -54,10 +64,11 @@
|
||||
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ }
|
||||
});
|
||||
function clearDraft() { try { localStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } recovered = null; }
|
||||
function changeVersion(event: Event) {
|
||||
async function changeVersion(event: Event) {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
if (dirty && !confirm('Discard your current edits and open this version?')) { target.value = selected; return; }
|
||||
clearDraft(); selected = target.value; success = '';
|
||||
const next = target.value; target.value = selected;
|
||||
if (dirty && !await confirmAction({title:'Discard current edits?',message:'Open the selected version and discard your current changes.',confirmLabel:'Discard edits',danger:true})) return;
|
||||
clearDraft(); selected = next; success = '';
|
||||
}
|
||||
function restoreDraft() {
|
||||
if (!recovered) return;
|
||||
@@ -65,7 +76,7 @@
|
||||
// recovered draft was started from a version that has since been deleted.
|
||||
text = recovered.text; notes = recovered.notes; recovered = null;
|
||||
}
|
||||
function discard() { if (confirm('Discard your unsaved changes?')) { text = original; notes = ''; clearDraft(); } }
|
||||
async function discard() { if (await confirmAction({title:'Discard unsaved changes?',message:'Restore the version you opened in this editor.',confirmLabel:'Discard changes',danger:true})) { text = original; notes = ''; clearDraft(); } }
|
||||
async function importFile(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -100,13 +111,13 @@
|
||||
<label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
|
||||
</div>
|
||||
{#if recovered && !loading}<div class="recovery"><span>A previous draft is available in this browser.</span><Button size="sm" onclick={restoreDraft} disabled={runningBetaId !== null}>Restore draft</Button><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button></div>{/if}
|
||||
{#if success}<div class="success" role="status">{success} <a href="/betas">Open betas →</a></div>{/if}
|
||||
{#if success}<div class="success" role="status">{success} <a href={"/betas/new?artifact=" + id + ":" + selected}>Test this version →</a></div>{/if}
|
||||
{#if error}<div class="error" role="alert">{error} {#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}</div>{/if}
|
||||
{#if loading}<p class="muted" role="status">Loading config contents…</p>
|
||||
{:else if loadedVersion}
|
||||
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
|
||||
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
|
||||
<div class="actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
|
||||
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
|
||||
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
|
||||
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { artifactId, draftText, scopeType, scopeValue }: { artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
|
||||
let { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
|
||||
let servers = $state<ConfigServer[]>([]);
|
||||
let selected = $state('');
|
||||
let inspection = $state<ConfigInspection | null>(null);
|
||||
@@ -13,7 +14,7 @@
|
||||
let raw = $state(false);
|
||||
let reload = $state(0);
|
||||
let ready = $state(false);
|
||||
const eligible = $derived(servers.filter(s => !artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false)));
|
||||
const eligible = $derived(servers.filter(s => (!fixedServer || s.name === fixedServer) && (!artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false))));
|
||||
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
|
||||
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
|
||||
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
|
||||
@@ -66,8 +67,8 @@
|
||||
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
|
||||
</div>
|
||||
<p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p>
|
||||
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!eligible.length}>
|
||||
{#each eligible as server}<option value={server.name}>{server.name}</option>{/each}
|
||||
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!!fixedServer || !eligible.length}>
|
||||
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
|
||||
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
||||
</select></label>
|
||||
{#if error}<div class="error" role="alert">{error}</div>
|
||||
@@ -129,13 +130,13 @@
|
||||
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
|
||||
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
strong { color: var(--accent-primary-hover); }
|
||||
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 11px; color: var(--text-secondary); margin-top: 6px; }
|
||||
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
|
||||
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
|
||||
b { font-weight: 400; color: var(--state-warning); }
|
||||
ol { list-style: none; padding: 0 0 0 14px; margin: 16px 0 0 4px; border-left: 2px solid var(--border-strong); }
|
||||
ol li { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
ol li.winner { color: var(--text-primary); }
|
||||
small { display: block; font-size: 10px; color: var(--text-secondary); margin-top: 3px; }
|
||||
small { display: block; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 3px; }
|
||||
.composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; }
|
||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import { mapLabel } from '$lib/script-library.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import SectionHeader from './primitives/SectionHeader.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -22,7 +25,9 @@
|
||||
let game = $state('');
|
||||
let scope = $state('');
|
||||
let status = $state('');
|
||||
let view = $state<'library' | 'inspect'>('library');
|
||||
let view = $state('library');
|
||||
let limit = $state(25);
|
||||
$effect(() => { query; game; scope; status; limit = 25; });
|
||||
let dialog: HTMLDialogElement;
|
||||
let kind = $state<'baseline' | 'config'>('config');
|
||||
let name = $state('');
|
||||
@@ -94,15 +99,12 @@
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Configs" eyebrow="Fleet configuration" subtitle="Build on a baseline. Fine-tune with overlays. Know where every value comes from.">
|
||||
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes. Inspect the settings applied to a server.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="overview">
|
||||
<div><span class="metric">{baselines.length}</span><span>Game baselines<small>The starting point for your servers</small></span></div>
|
||||
<div><span class="metric">{overlays.length}</span><span>Scoped overlays<small>Changes for a game or map</small></span></div>
|
||||
<div><span class="metric" class:pending={pending > 0}>{pending}</span><span>Awaiting promotion<small>Latest versions outside the promoted set</small></span></div>
|
||||
</div>
|
||||
<div class="navigation"><div class="view-switch" aria-label="Configs view"><button class:active={view === 'library'} aria-pressed={view === 'library'} onclick={() => view = 'library'}>Library</button><button class:active={view === 'inspect'} aria-pressed={view === 'inspect'} onclick={() => view = 'inspect'}>Inspect a server <span aria-hidden="true">↗</span></button></div><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh library</Button></div>
|
||||
<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>
|
||||
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
|
||||
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
|
||||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||
{#if view === 'inspect'}
|
||||
<ConfigInspector />
|
||||
@@ -128,14 +130,16 @@
|
||||
{/if}
|
||||
{#if filteredOverlays.length}
|
||||
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
|
||||
<div class="overlay-list">{#each filteredOverlays as artifact (artifact.id)}
|
||||
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_value}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
|
||||
<div class="overlay-list">{#each filteredOverlays.slice(0,limit) as artifact (artifact.id)}
|
||||
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
|
||||
{/each}</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
<ListFooter total={filteredOverlays.length} bind:limit label="overlays" />
|
||||
{#if artifacts.length}<p class="footnote">Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet versionBadges(artifact: Artifact)}
|
||||
@@ -163,14 +167,11 @@
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.overview { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.overview > div { display: flex; align-items: center; gap: 18px; padding: 24px; } .overview > div + div { border-left: 1px solid var(--border-subtle); }
|
||||
.metric { font-size: 32px; font-weight: 600; letter-spacing: -.04em; } .metric.pending { color: var(--state-warning); }
|
||||
.overview > div > span:last-child { font-size: var(--text-sm); } small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 5px; }
|
||||
.navigation, .view-switch, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.navigation, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
|
||||
.navigation, .section-heading, .card-top, .card-footer { justify-content: space-between; }
|
||||
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
|
||||
.view-switch { gap: 4px; }
|
||||
|
||||
button { background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 14px; cursor: pointer; }
|
||||
button.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
@@ -190,7 +191,7 @@
|
||||
.overlay-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.overlay-row { text-decoration: none; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 18px 20px; }
|
||||
.overlay-main { flex: 1; min-width: 0; } .overlay-main h3 { margin: 0 0 5px; font-size: var(--text-sm); }
|
||||
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px; background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
|
||||
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: var(--radius-md); background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
|
||||
.scope-label, .servers { color: var(--text-secondary); font-size: var(--text-xs); } .servers { min-width: 80px; text-align: right; }
|
||||
.footnote { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
|
||||
@@ -210,6 +211,11 @@
|
||||
label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.import { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .import input { flex: 1; }
|
||||
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
@media (max-width: 900px) { .overview > div { padding: 16px; gap: 12px; } .overview small { display: none; } .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
|
||||
@media (max-width: 600px) { .overview { grid-template-columns: 1fr; } .overview > div + div { border-left: 0; border-top: 1px solid var(--border-subtle); } .overview > div { padding: 12px 16px; } .metric { font-size: 24px; min-width: 30px; } .overview small { display: block; } .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
|
||||
@media (max-width: 900px) { .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
|
||||
@media (max-width: 600px) { .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
|
||||
.config-panel { display:grid; gap:20px; }
|
||||
.baseline-card { padding:16px; border-radius:var(--radius-md); background:var(--surface); }
|
||||
.card-footer { padding-top:12px; margin-top:12px; }
|
||||
.welcome { padding:24px; }
|
||||
.loading { padding:24px 0; }
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
|
||||
.setting span { min-width: 0; overflow-wrap: anywhere; }
|
||||
code { font: var(--text-sm) var(--font-mono); }
|
||||
small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 4px; }
|
||||
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
|
||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; }
|
||||
summary { cursor: pointer; }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { confirmation, resolveConfirmation } from '../confirm.js';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
</script>
|
||||
|
||||
<Dialog bind:open={() => $confirmation !== null, (open) => { if (!open) resolveConfirmation(false); }} title={$confirmation?.title ?? 'Confirm action'}>
|
||||
<p>{$confirmation?.message}</p>
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" onclick={() => resolveConfirmation(false)}>Cancel</Button>
|
||||
<Button variant={$confirmation?.danger ? 'danger' : 'primary'} onclick={() => resolveConfirmation(true)}>{$confirmation?.confirmLabel ?? 'Continue'}</Button>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
<style>p { margin: 0; white-space: pre-line; color: var(--text-secondary); line-height: var(--leading-body); }</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { toast } from '../toast.js';
|
||||
let { value, label = 'ID' }: { value: string; label?: string } = $props();
|
||||
async function copy() {
|
||||
try { await navigator.clipboard.writeText(value); toast(`${label} copied.`, { variant: 'success' }); }
|
||||
catch { toast('Copy unavailable. Select and copy the text directly.', { variant: 'warning' }); }
|
||||
}
|
||||
</script>
|
||||
<div class="copy"><code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}>Copy</Button></div>
|
||||
<style>.copy { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); } code { font: var(--text-xs) var(--font-mono); user-select: all; overflow-wrap: anywhere; }</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
@@ -46,7 +47,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="composer">
|
||||
<header><div><span class="eyebrow">Your lineup, ready to launch</span><h2>{preview?.blueprintName ?? 'Preparing your collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
@@ -80,9 +81,9 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: 11px; }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: 11px; }
|
||||
form, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: 11px; color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
form, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
@media (max-width: 600px) { .composer { padding: 20px; }.server-row { grid-template-columns: 1fr; gap: 12px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
@@ -103,7 +104,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<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}>
|
||||
<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><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>
|
||||
@@ -144,19 +145,19 @@
|
||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||||
|
||||
<style>
|
||||
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 { 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: 10px; color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
|
||||
.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-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); }
|
||||
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: 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: 8px; font-size: 13px; }.success { color: var(--accent-primary-hover); }
|
||||
.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: 10px; } h2 { font-size: 22px; } }
|
||||
@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>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||
<h2 id="destroy-title">Destroy container?</h2>
|
||||
<p class="name">{name}</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Deployment history and the saved server key are retained.</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and rollout details. Deployment history and the saved server key are retained.</p>
|
||||
<div class="notice">
|
||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||
Destruction waits until a live check confirms the server is empty. You can follow its progress on the deployment page.
|
||||
@@ -47,12 +47,12 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
|
||||
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .65); backdrop-filter: blur(4px); }
|
||||
form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; }
|
||||
p { line-height: 1.6; color: var(--text-secondary); }
|
||||
.name { font-family: var(--font-mono); color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.notice { padding: 16px; background: var(--surface-raised); border-radius: 8px; line-height: 1.6; }
|
||||
.notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; }
|
||||
strong { display: block; color: var(--state-warning); margin-bottom: 4px; }
|
||||
.irreversible { font-size: 13px; } .error { color: var(--state-danger); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number) => void } = $props();
|
||||
@@ -44,9 +45,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="House Party Protocol" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<dialog bind:this={dialog} aria-label="Destroy fleet" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="content">
|
||||
<header><span class="eyebrow">Call it a night</span><h2>House Party Protocol</h2></header>
|
||||
<header><span class="eyebrow">House Party Protocol</span><h2>Destroy fleet</h2></header>
|
||||
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
|
||||
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
@@ -63,12 +64,12 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }
|
||||
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
||||
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
|
||||
.eyebrow { display: block; color: var(--state-danger); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
|
||||
.eyebrow { display: block; color: var(--state-danger); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
|
||||
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: 8px; }
|
||||
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
|
||||
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
<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; }
|
||||
.eyebrow { display: block; font-size: var(--text-xs); 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); }
|
||||
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: var(--text-xs); } li span, small { color: var(--text-secondary); }
|
||||
p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
const uid = $props.id();
|
||||
|
||||
interface TabDef {
|
||||
key: string; // matches the log key in KNOWN_LOGS
|
||||
@@ -32,27 +36,33 @@
|
||||
// Default to the first tab once props are populated; don't overwrite
|
||||
// the user's later selection.
|
||||
$effect(() => {
|
||||
if (!active && tabs.length > 0) active = tabs[0].key;
|
||||
if (!tabs.some(tab => tab.key === active)) active = tabs[0]?.key ?? '';
|
||||
});
|
||||
let loading = $state(false);
|
||||
let autoRefresh = $state(true);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let refreshError = $state('');
|
||||
const pending = new Set<string>();
|
||||
let bodyEl = $state<HTMLPreElement | null>(null);
|
||||
let stickToBottom = $state(true);
|
||||
|
||||
async function fetchLog(key: string): Promise<void> {
|
||||
loading = true;
|
||||
const currentServer = server;
|
||||
const requestKey = currentServer + ':' + key;
|
||||
if (pending.has(requestKey)) return;
|
||||
pending.add(requestKey); loading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/servers/${encodeURIComponent(server)}/logs/${encodeURIComponent(key)}?lines=${lines}`
|
||||
);
|
||||
const body = (await res.json()) as LogResponse;
|
||||
data = { ...data, [key]: body };
|
||||
if (!res.ok || !body.ok) throw new Error(body.reason ?? 'Could not refresh log.');
|
||||
if (server === currentServer) { data = { ...data, [key]: body }; if (key === active) refreshError = ''; }
|
||||
} catch (err) {
|
||||
data = { ...data, [key]: { ok: false, reason: (err as Error).message } };
|
||||
if (server === currentServer && key === active) refreshError = (err as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
if (stickToBottom) {
|
||||
pending.delete(requestKey);
|
||||
if (server === currentServer && key === active) loading = false;
|
||||
if (stickToBottom && server === currentServer && key === active) {
|
||||
await tick();
|
||||
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
@@ -67,37 +77,18 @@
|
||||
stickToBottom = nearBottom;
|
||||
}
|
||||
|
||||
function switchTab(key: string): void {
|
||||
active = key;
|
||||
if (!data[key]) void fetchLog(key);
|
||||
}
|
||||
|
||||
function schedulePoll(): void {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
if (autoRefresh) {
|
||||
pollTimer = setInterval(() => {
|
||||
if (active) void fetchLog(active);
|
||||
}, pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAuto(): void {
|
||||
autoRefresh = !autoRefresh;
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
// Initial load
|
||||
function toggleAuto() { autoRefresh = !autoRefresh; }
|
||||
$effect(() => { server; data = {}; refreshError = ''; });
|
||||
$effect(() => {
|
||||
if (active && !data[active]) void fetchLog(active);
|
||||
});
|
||||
|
||||
// Reschedule when autoRefresh or active changes
|
||||
$effect(() => {
|
||||
schedulePoll();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
server;
|
||||
const key = active;
|
||||
const auto = autoRefresh;
|
||||
const interval = pollMs;
|
||||
if (!key) return;
|
||||
return untrack(() => {
|
||||
if (auto) return pollVisible(() => fetchLog(key), interval);
|
||||
void fetchLog(key);
|
||||
});
|
||||
});
|
||||
|
||||
function ago(iso: string | null | undefined): string {
|
||||
@@ -122,11 +113,7 @@
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="tabs">
|
||||
{#each tabs as t (t.key)}
|
||||
<button class="tab" class:on={active === t.key} onclick={() => switchTab(t.key)}>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} bind:value={active} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
||||
<div class="right">
|
||||
{#if current?.mtime}
|
||||
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
|
||||
@@ -141,18 +128,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !current}
|
||||
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
||||
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if}
|
||||
{#if !current && !refreshError}
|
||||
<div class="empty">loading…</div>
|
||||
{:else if !current.ok}
|
||||
{:else if current && !current.ok}
|
||||
<div class="error">error: {current.reason ?? 'unknown'}</div>
|
||||
{:else if current.missing}
|
||||
{:else if current?.missing}
|
||||
<div class="empty">
|
||||
log file not written yet
|
||||
{#if current.path}<br /><code>{current.path}</code>{/if}
|
||||
</div>
|
||||
{:else if !current.lines || current.lines.length === 0}
|
||||
{:else if current && (!current.lines || current.lines.length === 0)}
|
||||
<div class="empty">log is empty</div>
|
||||
{:else}
|
||||
{:else if current?.lines}
|
||||
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
|
||||
{#if current.truncated}
|
||||
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
|
||||
@@ -160,6 +149,7 @@
|
||||
<div class="foot">{current.lines.length} line{current.lines.length === 1 ? '' : 's'}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -176,24 +166,7 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.tab {
|
||||
background: transparent;
|
||||
color: var(--ink-300);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm) var(--r-sm) 0 0;
|
||||
padding: 8px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.tab:hover { color: var(--cream); background: var(--bg-card); }
|
||||
.tab.on {
|
||||
color: var(--cream);
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
border-bottom-color: var(--bg-card);
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
|
||||
let limit = $state(25);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
@@ -17,8 +19,9 @@
|
||||
<section>
|
||||
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
|
||||
{#if events.length}
|
||||
{#if events.length > limit}<Button size="sm" onclick={() => limit += 25}>Show earlier activity ({events.length - limit})</Button>{/if}
|
||||
<ol class="timeline">
|
||||
{#each events as event (event.id)}
|
||||
{#each events.slice(-limit) as event (event.id)}
|
||||
<li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -34,7 +37,7 @@
|
||||
time, .muted { color: var(--text-secondary); font-size: 13px; }
|
||||
p { margin: 0; line-height: 1.6; }
|
||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: 8px; padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
|
||||
.flow .done { color: var(--text-primary); }
|
||||
.flow .current { color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import RoundTelemetry from './RoundTelemetry.svelte';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { gametypeLabel } from '$ui/gametype.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import { mapTitle, serverTitle } from '$ui/server-labels.js';
|
||||
import { mapTitle, serverTitle, serverIdentity } from '$ui/server-labels.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import Stat from './primitives/Stat.svelte';
|
||||
@@ -71,6 +72,7 @@
|
||||
let showRoster = $derived(health === 'online' || health === 'stale');
|
||||
let title = $derived(serverTitle(name));
|
||||
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
|
||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).map);
|
||||
let gtLabel = $derived(gametypeLabel(gametype));
|
||||
|
||||
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
|
||||
@@ -101,7 +103,7 @@
|
||||
<div class="head">
|
||||
<div class="name-row">
|
||||
<StatusPip {health} />
|
||||
<strong class="name" title={name}>{title}</strong>
|
||||
<strong class="name"><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
|
||||
</div>
|
||||
{#if health === 'starting'}
|
||||
<Badge variant="info" uppercase>Starting</Badge>
|
||||
@@ -119,13 +121,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="map">{mapLabel}</span>
|
||||
{#if gtLabel}
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<span class="gt">{gtLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
|
||||
|
||||
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
|
||||
|
||||
@@ -194,7 +190,7 @@
|
||||
outline: 1px solid rgba(255, 176, 32, 0.28);
|
||||
}
|
||||
:global(.card.offline) {
|
||||
opacity: 0.62;
|
||||
border-color: color-mix(in srgb, var(--state-danger) 25%, var(--border-subtle));
|
||||
}
|
||||
|
||||
.restart-bar {
|
||||
@@ -232,9 +228,7 @@
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.players { flex-shrink: 0; }
|
||||
|
||||
@@ -249,9 +243,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.map { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
|
||||
.gt { color: var(--text-tertiary); }
|
||||
.dot { color: var(--text-quaternary); }
|
||||
|
||||
.timing {
|
||||
display: flex;
|
||||
@@ -265,7 +258,7 @@
|
||||
gap: 2px;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { serverIdentity } from '../server-labels.js';
|
||||
let { name, mapLabel, showGame = true }: { name: string; mapLabel?: string; showGame?: boolean } = $props();
|
||||
const identity = $derived(serverIdentity(name));
|
||||
</script>
|
||||
<span class="identity" title={name}><span class="map">{mapLabel ?? identity.map}</span>{#if identity.lobby !== null}<span class="lobby">Lobby {identity.lobby}</span>{/if}{#if showGame && identity.game}<small>{identity.game}</small>{/if}</span>
|
||||
<style>
|
||||
.identity { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
|
||||
.map { overflow-wrap: anywhere; }
|
||||
.lobby { white-space: nowrap; font-size: .85em; color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
small { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; font-weight: var(--weight-regular); }
|
||||
</style>
|
||||
@@ -45,8 +45,9 @@
|
||||
aria-label={ariaLabel}
|
||||
{title}
|
||||
href={disabled || loading ? undefined : href}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex={disabled || loading ? -1 : undefined}
|
||||
aria-busy={loading}
|
||||
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
|
||||
>
|
||||
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
|
||||
<span class="label">{@render children?.()}</span>
|
||||
@@ -60,6 +61,7 @@
|
||||
{type}
|
||||
{title}
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={loading}
|
||||
disabled={disabled || loading}
|
||||
{onclick}
|
||||
>
|
||||
@@ -81,7 +83,8 @@
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
line-height: 1;
|
||||
line-height: 1.35;
|
||||
min-height: 40px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
@@ -105,7 +108,7 @@
|
||||
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
|
||||
|
||||
/* Sizes */
|
||||
.s-sm { padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
|
||||
.s-sm { min-height: 32px; padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
|
||||
.s-md { padding: 9px 16px; font-size: var(--text-sm); }
|
||||
.s-lg { padding: 12px 22px; font-size: var(--text-base); }
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
{tabindex}
|
||||
{style}
|
||||
{onclick}
|
||||
onkeydown={(event: KeyboardEvent) => { if (role === 'button' && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); (event.currentTarget as HTMLElement).click(); } }}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
@@ -56,7 +57,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklab, var(--surface) 90%, transparent);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
@@ -77,12 +79,6 @@
|
||||
|
||||
/* Backdrop blur only where the browser supports it — some rendering
|
||||
backends downgrade poorly, and we don't want blurred noise. */
|
||||
@supports (backdrop-filter: blur(4px)) {
|
||||
.card {
|
||||
backdrop-filter: blur(10px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Padding scale */
|
||||
.p-none { padding: 0; }
|
||||
@@ -91,23 +87,21 @@
|
||||
.p-lg { padding: var(--space-6); }
|
||||
|
||||
/* Elevation */
|
||||
.e-flat { box-shadow: var(--shadow-ambient); }
|
||||
.e-flat { box-shadow: none; }
|
||||
.e-raised { box-shadow: var(--shadow-raised); }
|
||||
|
||||
.e-interactive {
|
||||
box-shadow: var(--shadow-ambient);
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.e-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-raised);
|
||||
border-color: var(--border-strong);
|
||||
background: color-mix(in oklab, var(--surface-raised) 92%, transparent);
|
||||
}
|
||||
.e-interactive:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring), var(--shadow-raised);
|
||||
}
|
||||
.e-interactive:active { transform: translateY(-1px); }
|
||||
@media (max-width: 480px) {
|
||||
.p-md, .p-lg { padding: var(--space-4); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let { open = $bindable(false), title, description, busy = false, wide = false, children, actions }:
|
||||
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; children?: Snippet; actions?: Snippet } = $props();
|
||||
const id = $props.id();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let returnFocus: HTMLElement | null = null;
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) dialog.close();
|
||||
});
|
||||
function close() { if (!busy) open = false; }
|
||||
async function closed() {
|
||||
open = false;
|
||||
await tick();
|
||||
if (returnFocus?.isConnected) returnFocus.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class:wide aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
|
||||
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
|
||||
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
|
||||
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
|
||||
<div class="body">{@render children?.()}</div>
|
||||
{#if actions}<footer>{@render actions()}</footer>{/if}
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
|
||||
dialog.wide { width: min(880px, calc(100vw - 32px)); }
|
||||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
|
||||
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
|
||||
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.body { padding: var(--space-6); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-subtle); background: var(--surface); position: sticky; bottom: 0; z-index: 1; }
|
||||
@media (max-width: 600px) { header, .body { padding: var(--space-4); } footer { padding: var(--space-4); } h2 { font-size: var(--text-lg); } }
|
||||
</style>
|
||||
@@ -6,20 +6,15 @@
|
||||
description?: string | null;
|
||||
icon?: Snippet;
|
||||
actions?: Snippet;
|
||||
spacious?: boolean;
|
||||
}
|
||||
|
||||
let { title, description = null, icon, actions }: Props = $props();
|
||||
let { title, description = null, icon, actions, spacious = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty" role="status">
|
||||
<div class="empty" class:spacious role="status">
|
||||
{#if icon}
|
||||
<div class="icon">{@render icon()}</div>
|
||||
{:else}
|
||||
<div class="icon default">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="title">{title}</h3>
|
||||
{#if description}<p class="desc">{description}</p>{/if}
|
||||
@@ -32,12 +27,13 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
padding: var(--space-6);
|
||||
gap: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklab, var(--surface) 60%, transparent);
|
||||
}
|
||||
.spacious { padding: var(--space-12) var(--space-6); }
|
||||
.icon {
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
@@ -11,22 +12,28 @@
|
||||
}
|
||||
|
||||
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
|
||||
const id = $props.id();
|
||||
setContext<FieldContext>(fieldContext, {
|
||||
get labelId() { return `${id}-label`; },
|
||||
get messageId() { return error || hint ? `${id}-message` : undefined; },
|
||||
get invalid() { return !!error; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
{#if label}
|
||||
<label class="label" for={forId}>
|
||||
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
|
||||
{label}
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
</label>
|
||||
</svelte:element>
|
||||
{/if}
|
||||
<div class="control" class:has-error={!!error}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="msg msg-error" role="alert">{error}</div>
|
||||
<div id={`${id}-message`} class="msg msg-error" role="alert">{error}</div>
|
||||
{:else if hint}
|
||||
<div class="msg msg-hint">{hint}</div>
|
||||
<div id={`${id}-message`} class="msg msg-hint">{hint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -40,9 +47,7 @@
|
||||
.label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--text-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.req { color: var(--state-danger); margin-left: 3px; }
|
||||
.control { display: flex; flex-direction: column; }
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Props {
|
||||
value?: string;
|
||||
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file';
|
||||
@@ -13,6 +16,7 @@
|
||||
onchange?: (e: Event) => void;
|
||||
oninput?: (e: Event) => void;
|
||||
accept?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -28,7 +32,8 @@
|
||||
mono = false,
|
||||
onchange,
|
||||
oninput,
|
||||
accept
|
||||
accept,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -44,6 +49,10 @@
|
||||
{readonly}
|
||||
{autocomplete}
|
||||
{accept}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{onchange}
|
||||
{oninput}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Button from './Button.svelte';
|
||||
let { total, limit = $bindable(25), step = 25, label = 'items' }: { total: number; limit?: number; step?: number; label?: string } = $props();
|
||||
</script>
|
||||
{#if total > step}
|
||||
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => limit += step}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
|
||||
{/if}
|
||||
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
|
||||
@@ -37,7 +37,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-8);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.crumbs {
|
||||
font-size: var(--text-xs);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -14,6 +17,7 @@
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onchange?: (e: Event) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,7 +28,8 @@
|
||||
required = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
onchange
|
||||
onchange,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -35,6 +40,10 @@
|
||||
{name}
|
||||
{required}
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{onchange}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Badge from './Badge.svelte';
|
||||
import { statusPresentation } from '../../status.js';
|
||||
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
|
||||
const status = $derived(statusPresentation(state));
|
||||
</script>
|
||||
<Badge variant={status.tone} {size}>{status.label}</Badge>
|
||||
@@ -12,9 +12,24 @@
|
||||
onchange?: (id: string) => void;
|
||||
ariaLabel?: string;
|
||||
size?: 'sm' | 'md';
|
||||
panelId?: string;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md' }: Props = $props();
|
||||
const uid = $props.id();
|
||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const enabled = tabs.filter(t => !t.disabled);
|
||||
if (!enabled.length) return;
|
||||
const index = enabled.findIndex(t => t.id === value);
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
|
||||
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length;
|
||||
select(enabled[next].id, false);
|
||||
document.getElementById(`${idPrefix}-${enabled[next].id}`)?.focus();
|
||||
}
|
||||
|
||||
function select(id: string, disabled: boolean | undefined) {
|
||||
if (disabled) return;
|
||||
@@ -30,7 +45,11 @@
|
||||
class="tab"
|
||||
class:active={value === t.id}
|
||||
role="tab"
|
||||
id={`${idPrefix}-${t.id}`}
|
||||
aria-selected={value === t.id}
|
||||
aria-controls={panelId}
|
||||
tabindex={value === t.id ? 0 : -1}
|
||||
onkeydown={onKey}
|
||||
disabled={t.disabled}
|
||||
onclick={() => select(t.id, t.disabled)}
|
||||
>
|
||||
@@ -52,7 +71,7 @@
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: color-mix(in oklab, var(--surface) 80%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tab {
|
||||
@@ -68,7 +87,7 @@
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-pill);
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
color var(--dur-fast) var(--ease-standard),
|
||||
background-color var(--dur-fast) var(--ease-standard);
|
||||
@@ -97,7 +116,7 @@
|
||||
background: var(--surface-overlay);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.tab.active .badge {
|
||||
background: var(--accent-primary);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Props {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
@@ -10,6 +13,7 @@
|
||||
readonly?: boolean;
|
||||
mono?: boolean;
|
||||
oninput?: (e: Event) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -22,7 +26,8 @@
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
mono = false,
|
||||
oninput
|
||||
oninput,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -36,6 +41,10 @@
|
||||
{required}
|
||||
{disabled}
|
||||
{readonly}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{oninput}
|
||||
></textarea>
|
||||
|
||||
Reference in New Issue
Block a user