Unify UI workflows and streamline fleet management
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { fleetServers as sharedFleet } from '$ui/stores/fleet.js';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import { betaSelectionHref, mapLabel } from '$lib/script-library.js';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import PluginGithubImport from './PluginGithubImport.svelte';
|
||||
import ConfigEditor from './ConfigEditor.svelte';
|
||||
@@ -60,9 +67,10 @@
|
||||
|
||||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||||
let uploadDialog = $state<HTMLDialogElement | null>(null);
|
||||
let fleetServers: ServerRow[] = [];
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
const fleetServers = $derived($sharedFleet);
|
||||
let loadError = $state('');
|
||||
let limit = $state(25);
|
||||
let request = 0;
|
||||
|
||||
const editedScopeValue = $derived.by(() => {
|
||||
if (scopeType === 'game') return scopeGame;
|
||||
@@ -82,39 +90,27 @@
|
||||
|
||||
function scopeLabel(s: Artifact): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
|
||||
}
|
||||
function stateVariant(s: Version['state']): 'online' | 'danger' | 'neutral' {
|
||||
return s === 'tested' ? 'online' :
|
||||
s === 'test-failed' ? 'danger' :
|
||||
'neutral';
|
||||
return s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : s.scope_value === 't5' ? 'Black Ops I' : s.scope_value === 't6' ? 'Black Ops II' : s.scope_value ?? 'Unscoped';
|
||||
}
|
||||
|
||||
async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> {
|
||||
const [detailRes, serversRes, scopeRes] = await Promise.all([
|
||||
fetch(`/api/scripts/${id}`),
|
||||
fetch('/api/servers'),
|
||||
fetch('/api/scope-options')
|
||||
]);
|
||||
if (!detailRes.ok) { loading = false; return; }
|
||||
const body = (await detailRes.json()) as {
|
||||
script: Artifact; versions: Version[];
|
||||
matchedServers: string[]; runningBetaId: number | null;
|
||||
};
|
||||
artifact = body.script;
|
||||
versions = body.versions;
|
||||
matchedServers = body.matchedServers;
|
||||
runningBetaId = body.runningBetaId;
|
||||
|
||||
if (serversRes.ok) {
|
||||
const s = (await serversRes.json()) as { servers: ServerRow[] };
|
||||
fleetServers = s.servers;
|
||||
}
|
||||
if (scopeRes.ok) {
|
||||
scopeOptions = (await scopeRes.json()) as ScopeOptions;
|
||||
}
|
||||
if (seedForm) seedScopeForm();
|
||||
loading = false;
|
||||
const requestedId = id;
|
||||
const current = ++request;
|
||||
try {
|
||||
const [detailRes, scopeRes] = await Promise.all([
|
||||
fetch('/api/scripts/' + requestedId),
|
||||
seedForm ? fetch('/api/scope-options') : Promise.resolve(null)
|
||||
]);
|
||||
if (!detailRes.ok) throw new Error(detailRes.status === 404 ? 'This file no longer exists.' : 'Could not refresh file details.');
|
||||
const body = await detailRes.json();
|
||||
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
|
||||
if (requestedId !== id || current !== request) return;
|
||||
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId;
|
||||
if (scopes) scopeOptions = scopes;
|
||||
if (seedForm) seedScopeForm();
|
||||
loadError = '';
|
||||
} catch (error) { if (requestedId === id && current === request) loadError = (error as Error).message; }
|
||||
finally { if (requestedId === id && current === request) loading = false; }
|
||||
}
|
||||
|
||||
function seedScopeForm(): void {
|
||||
@@ -126,17 +122,14 @@
|
||||
} else if (scopeType === 'map') {
|
||||
scopeMap = artifact.scope_value ?? '';
|
||||
const owner = fleetServers.find((sv) => sv.map === scopeMap);
|
||||
scopeGame = owner?.game ?? '';
|
||||
scopeGame = owner?.game ?? Object.keys(scopeOptions.mapsByGame).find(game => scopeOptions.mapsByGame[game].includes(scopeMap)) ?? '';
|
||||
} else {
|
||||
scopeGame = ''; scopeMap = '';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
poll = setInterval(() => load({ seedForm: false }), 5000);
|
||||
});
|
||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||
$effect(() => { id; loading = true; artifact = null; limit = 25; void load(); });
|
||||
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
|
||||
|
||||
function openScopeModal(): void {
|
||||
if (runningBetaId !== null) return;
|
||||
@@ -163,7 +156,10 @@
|
||||
}
|
||||
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
|
||||
if (!dialog) return;
|
||||
if (e.target === dialog) dialog.close();
|
||||
if (e.target === dialog && !uploadingVersion && !updatingScope) {
|
||||
const bounds = dialog.getBoundingClientRect();
|
||||
if (e.clientX < bounds.left || e.clientX > bounds.right || e.clientY < bounds.top || e.clientY > bounds.bottom) dialog.close();
|
||||
}
|
||||
}
|
||||
|
||||
function pickVersionFile(e: Event): void {
|
||||
@@ -189,7 +185,7 @@
|
||||
versionFile = null; versionNotes = '';
|
||||
uploadDialog?.close();
|
||||
await load();
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
uploadingVersion = false;
|
||||
}
|
||||
}
|
||||
@@ -218,17 +214,19 @@
|
||||
}
|
||||
scopeDialog?.close();
|
||||
await load();
|
||||
} finally {
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
updatingScope = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteArtifact(): Promise<void> {
|
||||
if (!confirm(`Delete this ${family}?`)) return;
|
||||
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'Delete failed'; return; }
|
||||
goto(basePath);
|
||||
if (!await confirmAction({title:'Delete ' + family + '?',message:'Delete ' + (artifact?.name ?? 'this file') + ' and its saved versions?', confirmLabel:'Delete', danger:true})) return;
|
||||
try {
|
||||
const response = await fetch('/api/scripts/' + id, {method:'DELETE'});
|
||||
const body = await response.json();
|
||||
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not delete file.');
|
||||
await goto(basePath);
|
||||
} catch (error) { flash = (error as Error).message; }
|
||||
}
|
||||
|
||||
let gameOptions = $derived([
|
||||
@@ -246,11 +244,12 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if !artifact}
|
||||
{:else if !artifact && !loadError}
|
||||
<EmptyState title="Not found" description="This {family} may have been deleted." />
|
||||
{:else}
|
||||
{:else if artifact}
|
||||
{@const a = artifact}
|
||||
<div class="page">
|
||||
<SectionHeader title={a.name} subtitle={a.target_rel_path}>
|
||||
@@ -303,7 +302,7 @@
|
||||
{:else if matchedServers.length === 0}
|
||||
<span class="warn">No matching servers</span>
|
||||
{:else}
|
||||
{matchedServers.join(', ')}
|
||||
{#each matchedServers as name}<a class="matched-server" href={"/servers/" + encodeURIComponent(name)}><ServerIdentity {name} /></a>{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -323,7 +322,7 @@
|
||||
<span class="k">Latest version</span>
|
||||
<span class="v mono">
|
||||
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
|
||||
<Badge variant={stateVariant(latestVersion.state)} size="sm">{latestVersion.state}</Badge>
|
||||
<StatusBadge state={latestVersion.state} size="sm" />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -344,7 +343,7 @@
|
||||
<EmptyState title="No versions yet" description="Upload a first version to get started." />
|
||||
{:else}
|
||||
<div class="version-list">
|
||||
{#each versions as v (v.id)}
|
||||
{#each versions.slice(0,limit) as v (v.id)}
|
||||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
|
||||
@@ -352,16 +351,18 @@
|
||||
<span class="v-notes">{v.notes ?? ''}
|
||||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||||
</span>
|
||||
<span class="v-when">{v.uploaded_at}</span>
|
||||
<time class="v-when" datetime={v.uploaded_at}>{new Date(v.uploaded_at).toLocaleString()}</time>
|
||||
{#if a.current_version_id === v.id}
|
||||
<Badge variant="online" size="sm">Promoted</Badge>
|
||||
{:else}
|
||||
<Badge variant={stateVariant(v.state)} size="sm">{v.state.replace('-', ' ')}</Badge>
|
||||
<StatusBadge state={v.state} size="sm" />
|
||||
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ListFooter total={versions.length} bind:limit label="versions" />
|
||||
</Card>
|
||||
|
||||
{#if family === 'plugin'}
|
||||
@@ -376,20 +377,20 @@
|
||||
{#if flash}<div class="alert">{flash}</div>{/if}
|
||||
|
||||
<!-- Scope dialog -->
|
||||
<dialog bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<dialog aria-label="Edit scope" oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<form class="dlg" onsubmit={saveScope}>
|
||||
<header class="dlg-head">
|
||||
<h3>Edit scope</h3>
|
||||
<button type="button" class="close" onclick={cancelScopeModal} aria-label="Close">×</button>
|
||||
<button type="button" class="close" disabled={updatingScope} onclick={cancelScopeModal} aria-label="Close">×</button>
|
||||
</header>
|
||||
<Field label="Scope">
|
||||
<div class="scope-row">
|
||||
<Select bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||||
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||||
{#if scopeType === 'game' || scopeType === 'map'}
|
||||
<Select bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
|
||||
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
|
||||
{/if}
|
||||
{#if scopeType === 'map'}
|
||||
<Select bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||||
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
@@ -404,11 +405,11 @@
|
||||
</dialog>
|
||||
|
||||
<!-- Upload dialog -->
|
||||
<dialog bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
|
||||
<dialog aria-label="Upload new version" oncancel={event => { if (uploadingVersion) event.preventDefault(); }} bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
|
||||
<form class="dlg" onsubmit={uploadVersion}>
|
||||
<header class="dlg-head">
|
||||
<h3>Upload new version</h3>
|
||||
<button type="button" class="close" onclick={cancelUploadModal} aria-label="Close">×</button>
|
||||
<button type="button" class="close" disabled={uploadingVersion} onclick={cancelUploadModal} aria-label="Close">×</button>
|
||||
</header>
|
||||
<Field label="File">
|
||||
<Input type="file" accept={family === 'plugin' ? '.dll' : undefined} onchange={pickVersionFile} disabled={uploadingVersion} />
|
||||
@@ -589,4 +590,5 @@
|
||||
.panel-head, .head-actions, .dlg-actions { flex-wrap: wrap; }
|
||||
.dlg { padding: var(--space-4); }
|
||||
}
|
||||
.matched-server { display:block; padding:5px 0; color:var(--text-primary); text-decoration:none; font-family:var(--font-body); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user