Files
electric-cherry/src/lib/ui/components/ArtifactDetail.svelte
T

593 lines
24 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
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 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);
let fleetServers: ServerRow[] = [];
let poll: ReturnType<typeof setInterval> | null = null;
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);
const showLatestSeparately = $derived(
latestVersion !== null && currentVersion !== null && latestVersion.id !== currentVersion.id
);
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';
}
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;
}
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 ?? '';
} else {
scopeGame = ''; scopeMap = '';
}
}
onMount(() => {
load();
poll = setInterval(() => load({ seedForm: false }), 5000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
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;
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) 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();
} finally {
uploadingVersion = false;
}
}
async function saveScope(e: Event): Promise<void> {
e.preventDefault();
if (updatingScope) 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 };
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
scopeDialog?.close();
await load();
} 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);
}
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>
{#if loading}
<div class="loading">Loading…</div>
{:else if !artifact}
<EmptyState title="Not found" description="This {family} may have been deleted." />
{:else}
{@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 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>
<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">Applies to</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}
{matchedServers.join(', ')}
{/if}
</span>
</div>
<div class="kv">
<span class="k">Current 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 — open a <a href="/betas">beta</a></span>
{/if}
</span>
</div>
{#if showLatestSeparately && latestVersion}
<div class="kv">
<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>
</span>
</div>
{/if}
</div>
</Card>
{#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 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>
<span class="v-size">{v.size} B</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>
<span class="v-when">{v.uploaded_at}</span>
{#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>
{/if}
</div>
{/each}
</div>
{/if}
</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">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog 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>
</header>
<Field label="Scope">
<div class="scope-row">
<Select bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
{#if scopeType === 'game' || scopeType === 'map'}
<Select bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
{/if}
{#if scopeType === 'map'}
<Select bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
{/if}
</div>
</Field>
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}>Cancel</Button>
<Button type="submit" variant="primary" disabled={updatingScope} loading={updatingScope}>
Save scope
</Button>
</div>
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
</form>
</dialog>
<!-- Upload dialog -->
<dialog 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>
</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">{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, 1fr) 130px 80px 1fr 220px 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-sha, .v-size, .v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.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); }
}
</style>