629 lines
31 KiB
Svelte
629 lines
31 KiB
Svelte
<script lang="ts">
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
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';
|
||
import type { PluginSource } from '$lib/plugin-github.js';
|
||
import { goto } from '$app/navigation';
|
||
import Card from './primitives/Card.svelte';
|
||
import Badge from './primitives/Badge.svelte';
|
||
import Button from './primitives/Button.svelte';
|
||
import SectionHeader from './primitives/SectionHeader.svelte';
|
||
import Breadcrumbs from './primitives/Breadcrumbs.svelte';
|
||
import EmptyState from './primitives/EmptyState.svelte';
|
||
import Field from './primitives/Field.svelte';
|
||
import Input from './primitives/Input.svelte';
|
||
import Select from './primitives/Select.svelte';
|
||
|
||
type Family = 'script' | 'config' | 'plugin';
|
||
|
||
interface Props { id: number; family: Family; }
|
||
let { id, family }: Props = $props();
|
||
|
||
const basePath = $derived(`/${family}s`);
|
||
const crumbLabel = $derived(family.charAt(0).toUpperCase() + family.slice(1) + 's');
|
||
|
||
interface Artifact {
|
||
id: number; name: string; target_rel_path: string;
|
||
scope_type: 'game' | 'map' | null; scope_value: string | null;
|
||
current_version_id: number | null;
|
||
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
||
created_at: string;
|
||
}
|
||
interface Version {
|
||
id: number; version: number; sha256: string; size: number;
|
||
release_version: string | null;
|
||
source: PluginSource | null;
|
||
notes: string | null; state: 'untested' | 'tested' | 'test-failed';
|
||
uploaded_at: string;
|
||
}
|
||
interface ServerRow { name: string; game: string | null; map: string | null; }
|
||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
||
|
||
let artifact = $state<Artifact | null>(null);
|
||
let versions = $state<Version[]>([]);
|
||
let matchedServers = $state<string[]>([]);
|
||
let installedVersions = $state<Array<{ server: string; version: number; versionId: number; recordedAt: string }>>([]);
|
||
interface ScopeImpact { before: string[]; after: string[]; added: string[]; removed: string[]; affected: string[]; composed: boolean; detached: string[]; }
|
||
let scopeReviewOpen = $state(false);
|
||
let scopeImpact = $state<ScopeImpact | null>(null);
|
||
let scopePreviewError = $state('');
|
||
let scopePreviewLoading = $state(false);
|
||
let scopeComplete = $state<{ count: number; composed: boolean; detached: number; href: string } | null>(null);
|
||
let runningBetaId = $state<number | null>(null);
|
||
let loading = $state(true);
|
||
let flash = $state<string | null>(null);
|
||
|
||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||
let scopeGame = $state('');
|
||
let scopeMap = $state('');
|
||
let updatingScope = $state(false);
|
||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||
|
||
let versionFile = $state<File | null>(null);
|
||
let versionNotes = $state('');
|
||
let releaseVersion = $state('');
|
||
let uploadingVersion = $state(false);
|
||
|
||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||
let uploadDialog = $state<HTMLDialogElement | 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;
|
||
if (scopeType === 'map') return scopeMap;
|
||
return '';
|
||
});
|
||
|
||
const currentVersion = $derived(
|
||
artifact?.current_version_id
|
||
? versions.find((v) => v.id === artifact!.current_version_id) ?? null
|
||
: null
|
||
);
|
||
const latestVersion = $derived(versions[0] ?? null);
|
||
|
||
$effect(() => {
|
||
const type = scopeType; const value = editedScopeValue; const artifactId = id;
|
||
if (!scopeReviewOpen) return;
|
||
scopeImpact = null; scopePreviewError = '';
|
||
if (type !== 'none' && !value) { scopePreviewLoading = false; return; }
|
||
const controller = new AbortController(); scopePreviewLoading = true;
|
||
void (async () => {
|
||
try {
|
||
const response = await fetch(`/api/scripts/${artifactId}/scope?${new URLSearchParams({ type, value })}`, { signal: controller.signal });
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not preview scope.');
|
||
if (!controller.signal.aborted) scopeImpact = body;
|
||
} catch (error) { if (!controller.signal.aborted) scopePreviewError = (error as Error).message; }
|
||
finally { if (!controller.signal.aborted) scopePreviewLoading = false; }
|
||
})();
|
||
return () => controller.abort();
|
||
});
|
||
|
||
function scopeLabel(s: Artifact): string {
|
||
if (!s.scope_type) return 'unscoped';
|
||
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 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; installedVersions = body.installedVersions ?? [];
|
||
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 {
|
||
if (!artifact) return;
|
||
scopeType = artifact.scope_type ?? 'none';
|
||
if (scopeType === 'game') {
|
||
scopeGame = artifact.scope_value ?? '';
|
||
scopeMap = '';
|
||
} else if (scopeType === 'map') {
|
||
scopeMap = artifact.scope_value ?? '';
|
||
const owner = fleetServers.find((sv) => sv.map === scopeMap);
|
||
scopeGame = owner?.game ?? Object.keys(scopeOptions.mapsByGame).find(game => scopeOptions.mapsByGame[game].includes(scopeMap)) ?? '';
|
||
} else {
|
||
scopeGame = ''; scopeMap = '';
|
||
}
|
||
}
|
||
|
||
$effect(() => { id; loading = true; artifact = null; limit = 25; void load(); });
|
||
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
|
||
|
||
function openScopeModal(): void {
|
||
if (runningBetaId !== null) return;
|
||
seedScopeForm();
|
||
flash = null;
|
||
scopeReviewOpen = true;
|
||
scopeDialog?.showModal();
|
||
}
|
||
function cancelScopeModal(): void {
|
||
seedScopeForm();
|
||
scopeReviewOpen = false;
|
||
scopeDialog?.close();
|
||
}
|
||
function openUploadModal(): void {
|
||
if (runningBetaId !== null) return;
|
||
releaseVersion = '';
|
||
versionFile = null;
|
||
versionNotes = '';
|
||
flash = null;
|
||
uploadDialog?.showModal();
|
||
}
|
||
function cancelUploadModal(): void {
|
||
versionFile = null;
|
||
versionNotes = '';
|
||
uploadDialog?.close();
|
||
}
|
||
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
|
||
if (!dialog) return;
|
||
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 {
|
||
const t = e.target as HTMLInputElement;
|
||
versionFile = t.files?.[0] ?? null;
|
||
}
|
||
|
||
async function uploadVersion(e: Event): Promise<void> {
|
||
e.preventDefault();
|
||
if (!versionFile || uploadingVersion) return;
|
||
uploadingVersion = true; flash = null;
|
||
const form = new FormData();
|
||
form.append('content', versionFile);
|
||
if (family === 'plugin') form.append('releaseVersion', releaseVersion.trim());
|
||
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
||
try {
|
||
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||
const body = (await res.json()) as { ok: boolean; error?: string; betaRequired?: boolean };
|
||
if (!res.ok || !body.ok) {
|
||
flash = body.error ?? `HTTP ${res.status}`;
|
||
return;
|
||
}
|
||
versionFile = null; versionNotes = '';
|
||
uploadDialog?.close();
|
||
await load();
|
||
} catch (error) { flash = (error as Error).message; } finally {
|
||
uploadingVersion = false;
|
||
}
|
||
}
|
||
|
||
async function saveScope(e: Event): Promise<void> {
|
||
e.preventDefault();
|
||
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
|
||
if (scopeType !== 'none' && !editedScopeValue) {
|
||
flash = `Pick a ${scopeType}`;
|
||
return;
|
||
}
|
||
updatingScope = true; flash = null;
|
||
try {
|
||
const res = await fetch(`/api/scripts/${id}/scope`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
scopeType: scopeType === 'none' ? null : scopeType,
|
||
scopeValue: scopeType === 'none' ? null : editedScopeValue
|
||
})
|
||
});
|
||
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string; detached?: string[] };
|
||
if (!res.ok || !body.ok) {
|
||
flash = body.error ?? `HTTP ${res.status}`;
|
||
return;
|
||
}
|
||
scopeComplete = { count: scopeImpact.affected.length, composed: scopeImpact.composed, detached: body.detached?.length ?? 0, href: scopeImpact.affected.length === 1 ? '/campaigns?server=' + encodeURIComponent(scopeImpact.affected[0]) : '/campaigns' };
|
||
scopeReviewOpen = false; scopeDialog?.close();
|
||
await load();
|
||
} catch (error) { flash = (error as Error).message; } finally {
|
||
updatingScope = false;
|
||
}
|
||
}
|
||
|
||
async function deleteArtifact(): Promise<void> {
|
||
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([
|
||
{ value: '', label: '— Pick a game —', disabled: true },
|
||
...scopeOptions.games.map((g) => ({ value: g, label: g }))
|
||
]);
|
||
let mapOptions = $derived([
|
||
{ value: '', label: '— Pick a map —', disabled: true },
|
||
...(scopeOptions.mapsByGame[scopeGame] ?? []).map((m) => ({ value: m, label: m }))
|
||
]);
|
||
let scopeTypeOptions = [
|
||
{ value: 'none', label: 'Unscoped' },
|
||
{ value: 'game', label: 'Game' },
|
||
{ value: 'map', label: 'Map' }
|
||
];
|
||
</script>
|
||
|
||
<svelte:head><title>{artifact?.name ?? crumbLabel} · Electric Cherry</title></svelte:head>
|
||
|
||
{#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 && !loadError}
|
||
<EmptyState title="Not found" description="This {family} may have been deleted." />
|
||
{:else if artifact}
|
||
{@const a = artifact}
|
||
<div class="page">
|
||
<SectionHeader title={a.name} subtitle={a.target_rel_path}>
|
||
{#snippet crumbs()}
|
||
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
|
||
{/snippet}
|
||
</SectionHeader>
|
||
|
||
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Running servers have not been changed. <a href={scopeComplete.href}>Review reconciliation →</a></div>{/if}
|
||
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={`/betas/${runningBetaId}`}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
|
||
|
||
{#if runningBetaId}
|
||
<div class="warn-banner">
|
||
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is running for this {family}.
|
||
Rollback or promote it before making changes.
|
||
</div>
|
||
{/if}
|
||
|
||
<Card padding="lg">
|
||
<div class="panel-head">
|
||
<h2>Details</h2>
|
||
<div class="head-actions">
|
||
<Button variant="secondary" size="sm" onclick={openScopeModal} disabled={runningBetaId !== null}>
|
||
Edit scope
|
||
</Button>
|
||
<Button variant="secondary" size="sm" onclick={openUploadModal} disabled={runningBetaId !== null}>
|
||
Upload new version
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<details class="file-settings"><summary>Target file, scope, and eligible servers</summary><div class="kv-grid">
|
||
<div class="kv">
|
||
<span class="k">Target file</span>
|
||
<span class="v mono">{a.target_rel_path}</span>
|
||
</div>
|
||
<div class="kv">
|
||
<span class="k">Scope</span>
|
||
<span class="v">
|
||
<Badge
|
||
variant={a.scope_type === 'game' ? 'info' : a.scope_type === 'map' ? 'warning' : 'neutral'}
|
||
size="sm"
|
||
>
|
||
{scopeLabel(a)}
|
||
</Badge>
|
||
</span>
|
||
</div>
|
||
<div class="kv">
|
||
<span class="k">Eligible servers</span>
|
||
<span class="v mono">
|
||
{#if !a.scope_type}
|
||
<span class="dim">Unscoped — deploy from a beta</span>
|
||
{:else if matchedServers.length === 0}
|
||
<span class="warn">No matching servers</span>
|
||
{:else}
|
||
{#each matchedServers as name}<a class="matched-server" href={"/servers/" + encodeURIComponent(name)}><ServerIdentity {name} /></a>{/each}
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
<div class="kv">
|
||
<span class="k">Promoted version</span>
|
||
<span class="v mono">
|
||
{#if currentVersion}
|
||
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(0, 8)}
|
||
<Badge variant="online" size="sm">Promoted</Badge>
|
||
{:else}
|
||
<span class="dim">Never promoted — test a version first</span>
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
</div></details>
|
||
</Card>
|
||
|
||
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {$timeFormat.dateTime(installed.recordedAt)}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
|
||
|
||
{#if a.kind === 'config' || a.kind === 'baseline'}
|
||
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
|
||
{runningBetaId} scopeType={a.scope_type} scopeValue={a.scope_value} onsaved={() => load({ seedForm: false })} />
|
||
{/if}
|
||
|
||
<Card padding="lg">
|
||
<div class="panel-head">
|
||
<h2>Version history</h2>
|
||
<Badge variant="neutral" size="sm">{versions.length}</Badge>
|
||
</div>
|
||
{#if versions.length === 0}
|
||
<EmptyState title="No versions yet" description="Upload a first version to get started." />
|
||
{:else}
|
||
<div class="version-list">
|
||
{#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-notes">{v.notes ?? ''}
|
||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||
</span>
|
||
<time class="v-when" datetime={v.uploaded_at}>{$timeFormat.dateTime(v.uploaded_at)}</time>
|
||
{#if a.current_version_id === v.id}
|
||
<Badge variant="online" size="sm">Promoted</Badge>
|
||
{:else}
|
||
<StatusBadge state={v.state} size="sm" />
|
||
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
|
||
{/if}
|
||
<details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<ListFooter total={versions.length} bind:limit label="versions" />
|
||
</Card>
|
||
|
||
{#if family === 'plugin'}
|
||
<div id="github-releases"><PluginGithubImport pluginId={id} targetPath={a.target_rel_path} disabled={runningBetaId !== null} onimport={() => load()} /></div>
|
||
{/if}
|
||
|
||
<Card padding="lg">
|
||
<div class="panel-head"><h2>Danger zone</h2></div>
|
||
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
|
||
</Card>
|
||
|
||
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
|
||
|
||
<!-- Scope dialog -->
|
||
<dialog aria-label="Edit scope" onclose={() => scopeReviewOpen = false} 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" disabled={updatingScope} onclick={cancelScopeModal} aria-label="Close">×</button>
|
||
</header>
|
||
<Field label="Scope">
|
||
<div class="scope-row">
|
||
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||
{#if scopeType === 'game' || scopeType === 'map'}
|
||
<Select ariaLabel="Scope game" bind:value={scopeGame} onchange={() => scopeMap = ''} options={gameOptions} disabled={updatingScope} />
|
||
{/if}
|
||
{#if scopeType === 'map'}
|
||
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||
{/if}
|
||
</div>
|
||
</Field>
|
||
{#if scopePreviewLoading}<p role="status">Reviewing eligible servers…</p>{:else if scopePreviewError}<p role="alert">{scopePreviewError}</p>{:else if scopeImpact}<section class="scope-impact" aria-label="Scope change review"><strong>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added · {scopeImpact.removed.length} removed</strong>{#if scopeImpact.added.length}<p>Added: {scopeImpact.added.join(', ')}</p>{/if}{#if scopeImpact.removed.length}<p>Removed: {scopeImpact.removed.join(', ')}</p>{/if}<p>{scopeImpact.composed ? 'This changes intended configuration composition. Live configuration files remain unchanged until a deployment or reconciliation.' : scopeType === 'none' ? `${scopeImpact.detached.length} recorded installations will become untracked. Files remain on servers; review reconciliation to remove orphaned files.` : 'Scope describes eligibility. Saving does not install files or restart servers; review reconciliation for existing promoted files.'}</p></section>{/if}
|
||
<div class="dlg-actions">
|
||
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}>Cancel</Button>
|
||
<Button type="submit" variant="primary" disabled={updatingScope || scopePreviewLoading || !scopeImpact} loading={updatingScope}>
|
||
Save reviewed scope
|
||
</Button>
|
||
</div>
|
||
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
|
||
</form>
|
||
</dialog>
|
||
|
||
<!-- Upload dialog -->
|
||
<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" disabled={uploadingVersion} onclick={cancelUploadModal} aria-label="Close">×</button>
|
||
</header>
|
||
<Field label="File">
|
||
<Input type="file" accept={family === 'plugin' ? '.dll' : undefined} onchange={pickVersionFile} disabled={uploadingVersion} />
|
||
</Field>
|
||
{#if family === 'plugin'}
|
||
<Field label="Release version" hint="Optional. Use the publisher’s version or release tag, such as v3.1.0.">
|
||
<Input bind:value={releaseVersion} placeholder="v3.1.0" disabled={uploadingVersion} />
|
||
</Field>
|
||
{/if}
|
||
<Field label="Notes" hint="Optional. What changed since the previous version.">
|
||
<Input bind:value={versionNotes} placeholder="what changed" disabled={uploadingVersion} />
|
||
</Field>
|
||
<div class="dlg-actions">
|
||
<Button variant="ghost" onclick={cancelUploadModal} disabled={uploadingVersion}>Cancel</Button>
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
disabled={uploadingVersion || !versionFile}
|
||
loading={uploadingVersion}
|
||
>
|
||
Upload version
|
||
</Button>
|
||
</div>
|
||
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
|
||
</form>
|
||
</dialog>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||
.loading { text-align: center; padding: var(--space-16); color: var(--text-tertiary); }
|
||
|
||
.warn-banner {
|
||
padding: var(--space-3) var(--space-4);
|
||
background: rgba(255, 176, 32, 0.06);
|
||
border: 1px solid rgba(255, 176, 32, 0.28);
|
||
border-radius: var(--radius-md);
|
||
color: var(--text-primary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.warn-banner a { color: var(--state-warning); text-decoration: underline; }
|
||
|
||
.panel-head {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
gap: var(--space-3); margin-bottom: var(--space-4);
|
||
}
|
||
.panel-head h2 {
|
||
margin: 0;
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-md);
|
||
font-weight: var(--weight-semibold);
|
||
color: var(--text-primary);
|
||
}
|
||
.head-actions { display: flex; gap: var(--space-2); }
|
||
|
||
.kv-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||
gap: var(--space-3);
|
||
}
|
||
.kv {
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
padding: var(--space-3) var(--space-4);
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.k {
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: var(--tracking-label);
|
||
color: var(--text-tertiary);
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
.v {
|
||
font-size: var(--text-sm);
|
||
color: var(--text-primary);
|
||
display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
|
||
word-break: break-word;
|
||
}
|
||
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||
.dim { color: var(--text-tertiary); }
|
||
.warn { color: var(--state-warning); }
|
||
.v a { color: var(--accent-primary-hover); }
|
||
|
||
.version-list { display: flex; flex-direction: column; gap: var(--space-1); }
|
||
.version-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto auto auto;
|
||
gap: var(--space-3);
|
||
align-items: center;
|
||
padding: var(--space-2) var(--space-3);
|
||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.version-row.current {
|
||
border-color: rgba(124, 212, 255, 0.35);
|
||
background: rgba(124, 212, 255, 0.04);
|
||
}
|
||
.v-num { color: var(--text-primary); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
||
.v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
|
||
.version-technical { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||
.version-technical span { display: block; margin-top: 8px; }
|
||
.scope-complete { padding: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||
.release-summary { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
|
||
.release-summary h2 { font-size: var(--text-lg); margin: 0 0 8px; }
|
||
.release-summary p, .installed p, .scope-impact p { color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||
.scope-impact { border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); }
|
||
.installed { font-size: var(--text-sm); }
|
||
.file-settings { font-size: var(--text-sm); }
|
||
.file-settings .kv-grid { margin-top: 16px; }
|
||
.v-notes { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-sm); }
|
||
.v-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow-y: auto; }
|
||
.v-notes a { display: block; color: var(--accent-primary-hover); margin-top: var(--space-2); }
|
||
|
||
.alert {
|
||
padding: var(--space-3) var(--space-4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--state-danger);
|
||
background: rgba(255, 86, 112, 0.06);
|
||
border: 1px solid rgba(255, 86, 112, 0.28);
|
||
font-family: var(--font-mono);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.alert-danger { background: rgba(255, 86, 112, 0.08); }
|
||
|
||
/* Dialogs */
|
||
dialog {
|
||
background: var(--surface);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-lg);
|
||
padding: 0;
|
||
max-width: 560px;
|
||
width: calc(100vw - 40px);
|
||
box-shadow: var(--shadow-raised);
|
||
}
|
||
dialog[open] { animation: dlg-in var(--dur-base) var(--ease-standard); }
|
||
@keyframes dlg-in {
|
||
from { opacity: 0; transform: translateY(6px) scale(0.98); }
|
||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||
}
|
||
dialog::backdrop {
|
||
background: rgba(0, 0, 0, 0.55);
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
}
|
||
.dlg {
|
||
display: flex; flex-direction: column; gap: var(--space-4);
|
||
padding: var(--space-6);
|
||
}
|
||
.dlg-head {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
}
|
||
.dlg h3 {
|
||
margin: 0;
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-md);
|
||
font-weight: var(--weight-semibold);
|
||
color: var(--text-primary);
|
||
}
|
||
.close {
|
||
appearance: none;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-tertiary);
|
||
font-size: var(--text-xl);
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.close:hover { color: var(--text-primary); background: var(--surface-raised); }
|
||
|
||
.scope-row { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
||
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
|
||
|
||
.dlg-actions { display: flex; gap: var(--space-2); justify-content: flex-end; }
|
||
|
||
@media (max-width: 1000px) {
|
||
.version-row { grid-template-columns: 1fr; }
|
||
}
|
||
@media (max-width: 720px) {
|
||
.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>
|