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