593 lines
24 KiB
Svelte
593 lines
24 KiB
Svelte
<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>
|