Files
electric-cherry/src/routes/plugins/+page.svelte
T

321 lines
18 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 { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { mapLabel } from '$lib/script-library.js';
import PluginGithubImport from '$ui/components/PluginGithubImport.svelte';
import PluginUpdates from '$ui/components/PluginUpdates.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
interface ArtifactRow {
id: number;
name: string;
target_rel_path: string;
scope_type: 'game' | 'map' | null;
scope_value: string | null;
current_version_id: number | null;
currentVersion: { id: number; version: number; release_version: string | null } | null;
kind: ArtifactKind;
latestVersion: {
id: number; version: number; sha256: string; size: number;
release_version: string | null;
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
} | null;
versionCount: number;
servers: string[];
runningBetaId: number | null;
}
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
let addOpen = $state(false);
let githubBusy = $state(false);
let importComplete = $state<{ pluginId: number; betaUrl: string; alreadyImported: boolean } | null>(null);
let method = $state('upload');
let updatesOpen = $state(false);
let search = $state('');
let game = $state('');
let status = $state('');
let sort = $state('name');
let limit = $state(25);
let mounted = $state(false);
afterNavigate(() => { mounted = true; });
let lastFilters = '';
$effect(() => { const key = JSON.stringify([search, game, status, sort]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
$effect(() => { if (mounted) saveLibraryContext({ q: search, game, status, sort, limit }, { sort: 'name', limit: 25 }); });
const visible = $derived.by(() => plugins.filter(a => (a.name + ' ' + a.target_rel_path).toLowerCase().includes(search.toLowerCase())
&& (!game || !a.scope_type || (a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? '')))
&& (!status || stateLabel(a) === status)).sort((a,b) => sort === 'recent' ? (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') : a.name.localeCompare(b.name)));
let plugins = $state<ArtifactRow[]>([]);
let importCount = $state(0);
let loading = $state(true);
let flash = $state<string | null>(null);
let uploading = $state(false);
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
let successBanner = $state<{ name: string; artifactId: number; version: number; release_version: string | null } | null>(null);
let newName = $state('');
let newReleaseVersion = $state('');
let newScopeType = $state<'none' | 'game' | 'map'>('none');
let newScopeGame = $state('');
let newScopeMap = $state('');
let newFile = $state<File | null>(null);
let newNotes = $state('');
const scopeValue = $derived.by(() => {
if (newScopeType === 'game') return newScopeGame;
if (newScopeType === 'map') return newScopeMap;
return '';
});
async function load(): Promise<void> {
try {
const [pluginRes, scopeRes] = await Promise.all([
fetch('/api/plugins'),
fetch('/api/scope-options')
]);
if (!pluginRes.ok) {
throw new Error('Could not refresh plugins. Your last loaded results are still shown.');
} else {
plugins = ((await pluginRes.json()) as { scripts?: ArtifactRow[] }).scripts ?? [];
}
flash = null;
if (scopeRes.ok) {
scopeOptions = ((await scopeRes.json()) as ScopeOptions | undefined) ?? { games: [], mapsByGame: {} };
}
} catch (err) {
flash = `Failed to load plugins: ${(err as Error).message}`;
} finally {
loading = false;
}
}
onMount(() => { const params = new URLSearchParams(window.location.search); search = params.get('q') ?? ''; game = params.get('game') ?? ''; status = params.get('status') ?? ''; sort = params.get('sort') === 'recent' ? 'recent' : 'name'; limit = libraryLimit(params); void load(); });
function pickFile(e: Event): void {
const t = e.target as HTMLInputElement;
newFile = t.files?.[0] ?? null;
if (!newName && newFile) newName = newFile.name.replace(/\.[^.]+$/, '');
}
async function createPlugin(e: Event): Promise<void> {
e.preventDefault();
if (uploading || !newFile || !newName.trim()) return;
if (newScopeType !== 'none' && !scopeValue) { flash = `Pick a ${newScopeType}`; return; }
uploading = true; flash = null;
const form = new FormData();
form.append('name', newName.trim());
form.append('kind', 'plugin');
form.append('releaseVersion', newReleaseVersion.trim());
form.append('scopeType', newScopeType);
if (scopeValue) form.append('scopeValue', scopeValue);
if (newNotes.trim()) form.append('notes', newNotes.trim());
form.append('content', newFile);
try {
const res = await fetch('/api/plugins', { method: 'POST', body: form });
const body = (await res.json()) as { ok: boolean; error?: string; script?: { id: number; name: string } };
if (!res.ok || !body.ok) { flash = body.error ?? `HTTP ${res.status}`; return; }
successBanner = body.script
? { name: body.script.name, artifactId: body.script.id, version: 1, release_version: newReleaseVersion.trim() || null }
: null;
newName = ''; newScopeType = 'none'; newScopeGame = ''; newScopeMap = ''; newNotes = ''; newFile = null;
newReleaseVersion = '';
addOpen = false;
await load();
} catch (error) { flash = (error as Error).message; } finally {
uploading = false;
}
}
function scopeLabel(a: ArtifactRow): string {
if (!a.scope_type) return 'unscoped';
return a.scope_type === 'map' ? mapLabel(a.scope_value ?? '') : a.scope_value === 't5' ? 'Black Ops I' : a.scope_value === 't6' ? 'Black Ops II' : a.scope_value ?? 'Unscoped';
}
function stateLabel(a: ArtifactRow): string {
if (a.runningBetaId) return 'testing';
if (!a.latestVersion) return 'empty';
if (a.latestVersion.id === a.current_version_id) return 'promoted';
return a.latestVersion.state;
}
let gameOptions = $derived([
{ value: '', label: '— Pick a game —', disabled: true },
...scopeOptions.games.map((g) => ({ value: g, label: g }))
]);
let mapOptions = $derived([
{ value: '', label: '— Pick a map —', disabled: true },
...(scopeOptions.mapsByGame[newScopeGame] ?? []).map((m) => ({ value: m, label: m }))
]);
let scopeTypeOptions = [
{ value: 'none', label: 'Unscoped' },
{ value: 'game', label: 'Game' },
{ value: 'map', label: 'Map' }
];
</script>
<svelte:head><title>Plugins · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Plugins" subtitle="Manage plugin versions and test changes before promotion.">
{#snippet actions()}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
</SectionHeader>
{#if importComplete}<div class="banner" role="status"><strong>{importComplete.alreadyImported ? 'This plugin version was already imported.' : 'Plugin imported.'}</strong><span><a href={'/plugins/' + importComplete.pluginId}>View version</a> · <a href={importComplete.betaUrl}>Start beta</a></span></div>{/if}
{#if successBanner}
<div class="banner" role="status">
<strong>{artifactVersionLabel(successBanner)} of "{successBanner.name}" uploaded — untested.</strong>
<span><a href="/plugins/{successBanner.artifactId}">Review the version and start a beta</a></span>
</div>
{/if}
{#if flash && !addOpen}<div class="ui-error" role="alert">{flash}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if loading || plugins.length || flash}<div class="ui-toolbar">
<input type="search" aria-label="Search plugins" bind:value={search} placeholder="Search names or files…" />
<select aria-label="Filter plugins by game" bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
<select aria-label="Filter plugins by status" bind:value={status}><option value="">All statuses</option><option value="untested">Needs testing</option><option value="testing">Beta active</option><option value="test-failed">Test failed</option><option value="tested">Test passed</option><option value="promoted">Promoted</option></select>
<select aria-label="Sort plugins" bind:value={sort}><option value="name">Name A–Z</option><option value="recent">Recently uploaded</option></select>
</div>{/if}
{#if game}<p class="filter-hint">Game results include unscoped plugins that can use explicitly selected beta servers.</p>{/if}
{#if plugins.length}<details ontoggle={event => updatesOpen = event.currentTarget.open}><summary>Check plugin updates</summary>{#if updatesOpen}{#key importCount}<PluginUpdates />{/key}{/if}</details>{/if}
<section class="section">
<div class="section-head">
<h2>All plugins</h2>
<Badge variant="neutral" size="sm">{plugins.length}</Badge>
</div>
{#if loading}
<div class="loading">Loading…</div>
{:else if visible.length === 0 && !flash}
<EmptyState title={plugins.length ? "No matching plugins" : "No plugins yet"} description={plugins.length ? "Try different filters." : "Add a plugin to upload a file or import a GitHub release."} />
{:else}
<div class="rows">
{#each visible.slice(0,limit) as a (a.id)}
<a class="row" href={contextHref(`/plugins/${a.id}`, $page.url)}>
<div class="row-top">
<span class="name">{a.name}</span>
<Badge
variant={a.scope_type === 'game' ? 'info' : a.scope_type === 'map' ? 'warning' : 'neutral'}
size="sm"
>
{scopeLabel(a)}
</Badge>
<StatusBadge state={stateLabel(a)} />
</div>
<div class="row-meta">
<span class="path">{a.target_rel_path}</span>
<span>{a.currentVersion ? `Promoted ${artifactVersionLabel(a.currentVersion)}` : 'Never promoted'}</span>
<span>Latest {artifactVersionLabel(a.latestVersion)} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
{#if a.servers.length}
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
{/if}
</div>
</a>
{/each}
</div>
<ListFooter total={visible.length} bind:limit label="plugins" />
{/if}
</section>
</div>
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
<Tabs tabs={[{id:'upload',label:'Upload file',disabled:uploading || githubBusy},{id:'github',label:'GitHub release',disabled:uploading || githubBusy}]} bind:value={method} ariaLabel="Plugin source" idPrefix="plugin-source" panelId="plugin-source-panel" />
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
{#if method === 'github'}<PluginGithubImport bind:busy={githubBusy} onimport={async result => { importComplete = result; importCount++; addOpen = false; await load(); }} />
{:else} <form onsubmit={createPlugin} class="upload-form">
<Field label="Name">
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
</Field>
<Field label="Release version" hint="Optional. Use the publisher’s version or release tag, such as v3.1.0.">
<Input bind:value={newReleaseVersion} placeholder="v3.1.0" disabled={uploading} />
</Field>
<Field label="Notes" hint="Optional. What changed in this version.">
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
</Field>
<Field label="Scope" hint="Where this plugin deploys.">
<div class="scope-row">
<Select ariaLabel="Scope type" bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
{#if newScopeType === 'game' || newScopeType === 'map'}
<Select ariaLabel="Scope game" bind:value={newScopeGame} onchange={() => newScopeMap = ''} options={gameOptions} disabled={uploading} />
{/if}
{#if newScopeType === 'map'}
<Select ariaLabel="Scope map" bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
{/if}
</div>
</Field>
<Field label="Plugin file (.dll)">
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} />
</Field>
<div class="form-actions ui-sticky-actions">
<Button
type="submit"
variant="primary"
disabled={uploading || !newFile || !newName}
loading={uploading}
>
Upload plugin
</Button>
{#if flash}<span class="flash" role="alert">{flash}</span>{/if}
</div>
</form>
{/if}
</div>
</Dialog>
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.filter-hint { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
.upload-form { margin-top: 20px; }
.banner {
display: flex; flex-direction: column; gap: 4px;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: rgba(124, 212, 255, 0.06);
border: 1px solid rgba(124, 212, 255, 0.22);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.banner a { color: var(--state-online); text-decoration: underline; }
.upload-form { display: grid; gap: var(--space-4); grid-template-columns: 1fr 1fr; }
.scope-row { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
.form-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
.flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); }
.section { display: flex; flex-direction: column; gap: var(--space-3); }
.section-head { display: flex; align-items: center; gap: var(--space-3); }
.section-head h2 { margin: 0; font-family: var(--font-body); font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); }
.rows { display: flex; flex-direction: column; gap: var(--space-2); }
.row {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-4) var(--space-5);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary); text-decoration: none;
transition: background-color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.row:hover { background: var(--surface-raised); border-color: var(--border-strong); }
.row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.row-top { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
.name { font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.row-meta { display: flex; gap: var(--space-5); color: var(--text-tertiary); font-family: var(--font-mono); font-size: var(--text-xs); flex-wrap: wrap; }
.path { color: var(--text-secondary); }
@media (max-width: 720px) { .upload-form { grid-template-columns: 1fr; } }
</style>