176 lines
11 KiB
Svelte
176 lines
11 KiB
Svelte
<script lang="ts">
|
||
import { onMount } 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, onimport }: {
|
||
pluginId?: number; targetPath?: string; disabled?: boolean; onimport?: () => 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 busy = $state(false);
|
||
let checking = $state(false);
|
||
let error = $state('');
|
||
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
|
||
|
||
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;
|
||
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.');
|
||
success = body;
|
||
await onimport?.();
|
||
if (startBeta) 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={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">
|
||
<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} />
|
||
</label>
|
||
<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 {new Date(release.publishedAt).toLocaleDateString()}</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">
|
||
<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></label>
|
||
{#if !pluginId}
|
||
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label>
|
||
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">T5 · Black Ops I</option><option value="t6">T6 · Black Ops II</option></select></label>
|
||
{/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 label { flex: 1 1 300px; }
|
||
label { display: grid; gap: var(--space-2); font-size: var(--text-sm); }
|
||
input, select { width: 100%; min-width: 0; padding: 10px 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); font: inherit; }
|
||
input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||
.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>
|