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

176 lines
11 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 } 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>