391 lines
14 KiB
Svelte
391 lines
14 KiB
Svelte
<script lang="ts">
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import { goto } from '$app/navigation';
|
||
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
|
||
import Card from './primitives/Card.svelte';
|
||
import Button from './primitives/Button.svelte';
|
||
import Badge from './primitives/Badge.svelte';
|
||
|
||
let {
|
||
pluginId,
|
||
targetPath,
|
||
disabled = false,
|
||
busy = $bindable(false),
|
||
onimport
|
||
}: {
|
||
pluginId?: number;
|
||
targetPath?: string;
|
||
disabled?: boolean;
|
||
busy?: boolean;
|
||
onimport?: (result: {
|
||
pluginId: number;
|
||
betaUrl: string;
|
||
alreadyImported: boolean;
|
||
}) => void | Promise<void>;
|
||
} = $props();
|
||
let url = $state('');
|
||
let name = $state('');
|
||
let game = $state('');
|
||
let release = $state<GithubPluginRelease | null>(null);
|
||
let assetId = $state('');
|
||
let update = $state<PluginUpdate | null>(null);
|
||
let checking = $state(false);
|
||
let error = $state('');
|
||
let success = $state<{
|
||
pluginId: number;
|
||
betaUrl: string;
|
||
sha256: string;
|
||
alreadyImported: boolean;
|
||
} | null>(null);
|
||
let active = true;
|
||
onDestroy(() => {
|
||
active = false;
|
||
});
|
||
|
||
function showRelease(value: GithubPluginRelease) {
|
||
release = value;
|
||
const preferred = update?.source?.asset_name ?? targetPath?.split('/').pop();
|
||
assetId = String(
|
||
value.assets.find((a) => a.name === preferred)?.id ??
|
||
(value.assets.length === 1 ? value.assets[0].id : '')
|
||
);
|
||
if (!pluginId && !name) name = value.repository.split('/')[1];
|
||
url = value.url;
|
||
error = '';
|
||
success = null;
|
||
}
|
||
|
||
async function checkUpdates() {
|
||
if (!pluginId || checking) return;
|
||
checking = true;
|
||
error = '';
|
||
try {
|
||
const response = await fetch(`/api/plugins/updates?pluginId=${pluginId}`);
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not check for updates.');
|
||
update = body.updates[0];
|
||
if (!url && update?.source) url = `https://github.com/${update.source.repository}`;
|
||
} catch (e) {
|
||
error = (e as Error).message;
|
||
} finally {
|
||
checking = false;
|
||
}
|
||
}
|
||
onMount(() => {
|
||
void checkUpdates();
|
||
});
|
||
|
||
async function preview(event: SubmitEvent) {
|
||
event.preventDefault();
|
||
if (busy || disabled) return;
|
||
busy = true;
|
||
error = '';
|
||
release = null;
|
||
success = null;
|
||
try {
|
||
const response = await fetch(
|
||
`/api/plugins/github/release?url=${encodeURIComponent(url.trim())}`
|
||
);
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not load release.');
|
||
showRelease(body.release);
|
||
} catch (e) {
|
||
error = (e as Error).message;
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
|
||
async function importRelease(startBeta: boolean) {
|
||
if (!release || !assetId || busy || disabled) return;
|
||
const startPath = window.location.pathname;
|
||
busy = true;
|
||
error = '';
|
||
success = null;
|
||
try {
|
||
const response = await fetch('/api/plugins/github/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
url: release.url,
|
||
assetId: Number(assetId),
|
||
pluginId,
|
||
name: name.trim(),
|
||
scopeType: game ? 'game' : null,
|
||
scopeValue: game || null
|
||
})
|
||
});
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
|
||
if (!active) return;
|
||
success = body;
|
||
await onimport?.(body);
|
||
if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl);
|
||
else if (pluginId) await checkUpdates();
|
||
} catch (e) {
|
||
error = (e as Error).message;
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<Card padding="lg">
|
||
<section class="github" aria-label="GitHub plugin releases">
|
||
<div class="heading">
|
||
<div>
|
||
<h2>{pluginId ? 'GitHub releases' : 'Import from GitHub'}</h2>
|
||
<p>Choose a release and DLL, then test it with your fleet.</p>
|
||
</div>
|
||
{#if pluginId}<Button
|
||
size="sm"
|
||
onclick={checkUpdates}
|
||
loading={checking}
|
||
disabled={busy}>Check for updates</Button
|
||
>{/if}
|
||
</div>
|
||
{#if checking}<p role="status">Checking GitHub…</p>{/if}
|
||
{#if update?.source}
|
||
<p>
|
||
Following <a
|
||
href={`https://github.com/${update.source.repository}`}
|
||
target="_blank"
|
||
rel="noreferrer">{update.source.repository}</a
|
||
>
|
||
· {update.source.asset_name}
|
||
</p>
|
||
{/if}
|
||
{#if update?.status === 'available' && update.release}
|
||
<div class="update">
|
||
<Badge variant="info">New release: {update.release.tag}</Badge>
|
||
<Button
|
||
size="sm"
|
||
disabled={busy || disabled}
|
||
onclick={() => showRelease(update!.release!)}>Review update</Button
|
||
>
|
||
</div>
|
||
{:else if update?.status === 'imported' && update.release}
|
||
<div class="update">
|
||
<span>{update.release.tag} is already imported and available for testing.</span>
|
||
<Button
|
||
size="sm"
|
||
href={`/betas?pluginId=${pluginId}&versionId=${update.versionId}`}
|
||
{disabled}>Start beta</Button
|
||
>
|
||
</div>
|
||
{:else if update?.status === 'current'}
|
||
<p role="status">The latest stable release is promoted.</p>
|
||
{:else if update?.status === 'ahead'}
|
||
<p role="status">Your imported release is newer than GitHub’s latest stable release.</p>
|
||
{:else if update?.status === 'error'}
|
||
<p class="error" role="alert">{update.error}</p>
|
||
{:else if update?.status === 'untracked'}
|
||
<p>Import a release below to track updates for this plugin.</p>
|
||
{/if}
|
||
<form onsubmit={preview} class="lookup">
|
||
<Field label="Repository or release URL">
|
||
<Input
|
||
type="url"
|
||
bind:value={url}
|
||
oninput={() => {
|
||
release = null;
|
||
success = null;
|
||
}}
|
||
required
|
||
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0"
|
||
disabled={busy || disabled}
|
||
/>
|
||
</Field>
|
||
<Button
|
||
type="submit"
|
||
loading={busy && !release}
|
||
disabled={busy || disabled || !url.trim()}>Preview release</Button
|
||
>
|
||
</form>
|
||
{#if disabled}<p>Finish the running beta before importing a release.</p>{/if}
|
||
{#if release}
|
||
<div class="preview">
|
||
<div class="heading">
|
||
<h3>{release.name || release.tag}</h3>
|
||
<Badge variant={release.prerelease ? 'warning' : 'neutral'}
|
||
>{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}</Badge
|
||
>
|
||
</div>
|
||
<p>
|
||
<a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> ·
|
||
Published {$timeFormat.date(release.publishedAt)}
|
||
</p>
|
||
<details open>
|
||
<summary>Release notes</summary>
|
||
<pre>{release.notes || 'No release notes provided.'}</pre>
|
||
</details>
|
||
{#if release.assets.length === 0}
|
||
<p>
|
||
No importable DLL assets found. Choose another release or upload a DLL
|
||
manually. Direct DLL assets up to 64 MB are supported.
|
||
</p>
|
||
{:else}
|
||
<div class="fields">
|
||
<Field label="DLL asset">
|
||
<Select bind:value={assetId} disabled={busy || disabled}>
|
||
<option value="" disabled>Choose a DLL…</option>
|
||
{#each release.assets as asset}<option value={String(asset.id)}
|
||
>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option
|
||
>{/each}
|
||
</Select></Field
|
||
>
|
||
{#if !pluginId}
|
||
<Field label="Plugin name">
|
||
<Input
|
||
bind:value={name}
|
||
disabled={busy}
|
||
placeholder="t6-gsc-utils"
|
||
/></Field
|
||
>
|
||
<Field label="Deploy scope">
|
||
<Select bind:value={game} disabled={busy}
|
||
><option value="">Unscoped — select servers in a beta</option
|
||
><option value="t5">Black Ops I</option><option value="t6"
|
||
>Black Ops II</option
|
||
></Select
|
||
></Field
|
||
>
|
||
{/if}
|
||
</div>
|
||
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
|
||
<div class="actions">
|
||
<Button
|
||
variant="primary"
|
||
disabled={busy || disabled || !assetId || (!pluginId && !name.trim())}
|
||
loading={busy}
|
||
onclick={() => importRelease(true)}>Import and test</Button
|
||
>
|
||
<Button
|
||
disabled={busy || disabled || !assetId || (!pluginId && !name.trim())}
|
||
onclick={() => importRelease(false)}>Import only</Button
|
||
>
|
||
</div>
|
||
<p>
|
||
Imports are saved for testing. Choose servers and a test plan before
|
||
starting a beta.
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{#if success}
|
||
<div class="success" role="status">
|
||
<strong
|
||
>{success.alreadyImported
|
||
? 'This binary is already imported.'
|
||
: 'Release imported.'}</strong
|
||
>
|
||
<a href={`/plugins/${success.pluginId}`}>View plugin</a> ·
|
||
<a href={success.betaUrl}>Start beta</a>
|
||
<span class="checksum">SHA-256: {success.sha256}</span>
|
||
</div>
|
||
{/if}
|
||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||
</section>
|
||
</Card>
|
||
|
||
<style>
|
||
.github,
|
||
.preview {
|
||
display: grid;
|
||
gap: var(--space-4);
|
||
}
|
||
.heading,
|
||
.actions,
|
||
.update {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-3);
|
||
flex-wrap: wrap;
|
||
}
|
||
.actions {
|
||
justify-content: flex-start;
|
||
}
|
||
h2,
|
||
h3,
|
||
p {
|
||
margin: 0;
|
||
}
|
||
h2 {
|
||
font-size: var(--text-md);
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
h3 {
|
||
font-size: var(--text-md);
|
||
}
|
||
p {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
line-height: 1.6;
|
||
}
|
||
.heading p {
|
||
margin-top: var(--space-2);
|
||
}
|
||
.lookup {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: var(--space-3);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.lookup :global(.field) {
|
||
flex: 1 1 300px;
|
||
}
|
||
|
||
.fields {
|
||
display: grid;
|
||
gap: var(--space-3);
|
||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||
}
|
||
.preview {
|
||
padding-top: var(--space-4);
|
||
border-top: 1px solid var(--border-subtle);
|
||
}
|
||
a {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
pre {
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
max-height: 280px;
|
||
overflow-y: auto;
|
||
font: inherit;
|
||
font-size: var(--text-sm);
|
||
color: var(--text-secondary);
|
||
line-height: 1.6;
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.error {
|
||
color: var(--state-danger);
|
||
}
|
||
.success {
|
||
display: block;
|
||
padding: var(--space-3);
|
||
background: var(--surface-sunken);
|
||
border-radius: var(--radius-md);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.success strong,
|
||
.checksum {
|
||
display: block;
|
||
margin-bottom: var(--space-2);
|
||
}
|
||
.checksum {
|
||
overflow-wrap: anywhere;
|
||
font: var(--text-xs) var(--font-mono);
|
||
margin-top: var(--space-2);
|
||
}
|
||
</style>
|