Files
electric-cherry/src/lib/ui/components/PluginGithubImport.svelte
T
2026-10-10 00:19:48 -04:00

391 lines
14 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 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>