Files
electric-cherry/src/lib/ui/components/ScriptUploadDrawer.svelte
T
2026-10-12 18:46:57 -04:00

670 lines
26 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 { manifestVersion, scriptVersionError } from '$lib/script-version.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { plural } from '$ui/plural.js';
import { gameTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte';
import {
betaSelectionHref,
mapLabel,
scopeLabel,
type BetaSelection,
type LibraryScript
} from '$lib/script-library.js';
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } =
$props();
interface Duplicate {
scriptId: number;
versionId: number;
version: number;
release_version: string | null;
}
interface Upload {
key: number;
file: File;
target: number | 'new' | '';
name: string;
releaseVersion: string;
duplicates: Duplicate[];
inspecting: boolean;
inspected: boolean;
error: string;
result:
(BetaSelection & { version: number; release_version: string; reused: boolean }) | null;
}
let dialog: HTMLDialogElement;
let input: HTMLInputElement;
let queue = $state<Upload[]>([]);
let busy = $state(false);
let notes = $state('');
let manifest = $state<Record<string, string>>({});
function applyManifest(versions: Record<string, string>) {
manifest = versions;
for (const item of queue) {
if (!item.result && !item.releaseVersion.trim())
item.releaseVersion = manifestVersion(manifest, item.file.name);
}
}
let scopeType = $state<'none' | 'game' | 'map'>('none');
let scopeGame = $state('');
let scopeMap = $state('');
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({
games: [],
mapsByGame: {}
});
let servers = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
let scopeLoaded = $state(false);
let message = $state('');
let preferredTarget = $state<number | null>(null);
let nextKey = 0;
let activeFile = $state('');
const checkedCount = $derived(queue.filter((q) => q.inspected).length);
const pending = $derived(queue.filter((q) => !q.result));
const inspecting = $derived(queue.some((q) => q.inspecting));
const hasNew = $derived(pending.some((q) => q.target === 'new'));
const scopeValue = $derived(
scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : ''
);
const matches = $derived(
servers.filter((s) => (scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue))
);
const ready = $derived(
pending.length > 0 &&
!busy &&
!inspecting &&
pending.every((q) => q.inspected && !validation(q)) &&
(!hasNew || scopeType === 'none' || (scopeLoaded && !!scopeValue))
);
const results = $derived(queue.flatMap((q) => (q.result ? [q.result] : [])));
const testable = $derived(
results.filter((r) => {
const s = scripts.find((s) => s.id === r.scriptId);
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
})
);
export function open(scriptId?: number) {
// Reopening preserves unfinished work; a targeted upload starts a fresh review
// only when there is no pending file to lose.
if (!pending.length) {
queue = [];
manifest = {};
notes = '';
message = '';
preferredTarget = scriptId ?? null;
}
dialog.showModal();
if (!scopeLoaded) void loadScope();
}
async function loadScope() {
try {
const [scopeRes, serversRes] = await Promise.all([
fetch('/api/scope-options'),
fetch('/api/servers')
]);
if (!scopeRes.ok || !serversRes.ok)
throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
scopeOptions = await scopeRes.json();
servers = (await serversRes.json()).servers;
scopeLoaded = true;
} catch (e) {
message = (e as Error).message;
}
}
function matchingScripts(file: File) {
return scripts.filter(
(s) =>
s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ===
file.name.toLowerCase()
);
}
export async function addFiles(files: File[]) {
if (busy || inspecting || !files.length) return;
if (!dialog.open) open();
const items = files.map((file) => {
const matches = matchingScripts(file);
const item: Upload = {
key: nextKey++,
file,
target:
preferredTarget ??
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
name: file.name.replace(/\.[^.]+$/, ''),
releaseVersion: manifestVersion(manifest, file.name),
duplicates: [],
inspecting: true,
inspected: false,
error: '',
result: null
};
return item;
});
queue = [...queue, ...items];
for (const item of items) await inspect(queue.find((q) => q.key === item.key)!);
preferredTarget = null;
}
async function inspect(item: Upload) {
activeFile = item.file.name;
item.inspecting = true;
item.error = '';
try {
const form = new FormData();
form.append('content', item.file);
const res = await fetch('/api/scripts/inspect', { method: 'POST', body: form });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
item.duplicates = body.duplicates;
item.inspected = true;
} catch (e) {
item.error = (e as Error).message;
} finally {
item.inspecting = false;
activeFile = '';
}
}
function duplicate(item: Upload) {
return item.duplicates.find(
(d) => d.scriptId === item.target && d.release_version === item.releaseVersion.trim()
);
}
function validation(item: Upload): string {
const versionError = scriptVersionError(item.releaseVersion);
if (versionError) return versionError;
if (item.target === '')
return 'Several scripts use this filename. Choose which script to update.';
if (item.target === 'new') {
if (!item.name.trim()) return 'Give this script a name.';
if (scripts.some((s) => s.name === item.name.trim()))
return 'This name already exists. Choose a new name or update that script.';
if (
queue.some(
(q) =>
q.key !== item.key &&
!q.result &&
q.target === 'new' &&
q.name.trim() === item.name.trim()
)
)
return 'Use a distinct name for each new script.';
} else {
const script = scripts.find((s) => s.id === item.target);
if (!script) return 'This script is no longer available.';
if (script.runningBetaId !== null)
return 'Finish the active beta before uploading another version.';
if (queue.some((q) => q.key !== item.key && !q.result && q.target === item.target))
return 'Choose one file per existing script in this batch.';
}
return '';
}
async function upload(prepareBeta: boolean) {
if (!ready) return;
busy = true;
message = '';
try {
for (const item of queue.filter((q) => !q.result)) {
activeFile = item.file.name;
item.error = '';
try {
const form = new FormData();
form.append('content', item.file);
form.append('notes', notes.trim());
form.append('releaseVersion', item.releaseVersion.trim());
const creating = item.target === 'new';
if (creating) {
form.append('name', item.name.trim());
form.append('scopeType', scopeType);
if (scopeValue) form.append('scopeValue', scopeValue);
} else form.append('reuseExisting', 'true');
const res = await fetch(
creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`,
{ method: 'POST', body: form }
);
const body = await res.json();
if (!res.ok || !body.ok)
throw new Error(body.error ?? 'Upload failed. Try again.');
item.result = {
scriptId: creating ? body.script.id : (item.target as number),
versionId: creating ? body.versionId : body.version.id,
version: creating ? 1 : body.version.version,
release_version: item.releaseVersion.trim(),
reused: !!body.reused
};
} catch (e) {
item.error = (e as Error).message;
}
}
await onuploaded();
if (queue.some((q) => !q.result))
message =
'Some files need attention. Successful uploads are saved; retry only the remaining files.';
else {
message = 'Your files are ready. Choose servers and a test plan in beta setup.';
// Read from refreshed props after Svelte applies the parent update.
await tick();
if (testable.length !== results.length)
message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
if (prepareBeta && testable.length === results.length && testable.length) {
dialog.close();
await goto(betaSelectionHref(testable));
}
}
} catch (e) {
message = (e as Error).message;
} finally {
busy = false;
activeFile = '';
}
}
function close(event?: Event) {
if (busy || inspecting) {
event?.preventDefault();
return;
}
dialog.close();
}
</script>
<dialog bind:this={dialog} aria-labelledby="upload-title" oncancel={close}>
<div class="drawer">
<header>
<div>
<span class="eyebrow">Prepare your next release</span>
<h2 id="upload-title">Upload scripts</h2>
</div>
<Button
variant="ghost"
ariaLabel="Close upload drawer"
disabled={busy || inspecting}
onclick={() => close()}>✕</Button
>
</header>
<p class="intro">
Drop files anywhere or choose them below. Review each destination before uploading.
</p>
<div class="dropzone">
<strong
>{preferredTarget
? `New version for ${scripts.find((s) => s.id === preferredTarget)?.name ?? 'script'}`
: 'One file or a whole batch'}</strong
>
<span>Existing filenames are recognized automatically.</span>
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button
>
<input
class="file-input"
bind:this={input}
type="file"
multiple
aria-label="Choose script files"
onchange={(event) => {
void addFiles([...(event.currentTarget.files ?? [])]);
event.currentTarget.value = '';
}}
/>
</div>
<ScriptVersionManifest disabled={busy || inspecting} onload={applyManifest} />
<div class="queue" aria-live="polite">
{#each queue as item (item.key)}
<section class="upload-item" aria-label={item.file.name}>
<div class="file-heading">
<strong>{item.file.name}</strong><span
>{(item.file.size / 1024).toFixed(1)} KB</span
>
<Button
variant="ghost"
size="sm"
disabled={busy || inspecting}
ariaLabel={`Remove ${item.file.name}`}
onclick={() => (queue = queue.filter((q) => q.key !== item.key))}
>Remove</Button
>
</div>
{#if item.result}
<p class="success">
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as {artifactVersionLabel(
item.result,
'script'
)} ·
<a href="/scripts/{item.result.scriptId}">View script →</a>
</p>
{:else}
<Field
label="Release version"
hint="Required. Use MAJOR.MINOR.PATCH, such as 1.2.0."
>
<Input
bind:value={item.releaseVersion}
required
maxlength={80}
placeholder="1.2.0"
disabled={busy}
oninput={() => (item.error = '')}
/>
</Field>
<Field label="Destination">
<Select
bind:value={item.target}
disabled={busy || item.inspecting}
onchange={() => (item.error = '')}
>
<option value="" disabled>Choose a script…</option><option
value="new">Create a new script</option
>
{#each scripts as s}<option value={s.id}
>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null
? ' · beta active'
: ''}</option
>{/each}
</Select></Field
>
{#if item.target === 'new'}<Field label="Script name">
<Input bind:value={item.name} disabled={busy} /></Field
>
{:else if item.target !== ''}{@const s = scripts.find(
(s) => s.id === item.target
)}
<p class="note">
Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}
</p>{/if}
{#if item.inspecting}<p class="note">Checking file contents…</p>
{:else if duplicate(item)}<p class="success">
Already uploaded as {artifactVersionLabel(
duplicate(item),
'script'
)}. We’ll reuse that version.
</p>
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
New version for {scripts.find((s) => s.id === item.target)?.name}
</p>{/if}
{#if validation(item)}<p class="warning">{validation(item)}</p>{/if}
{#if item.error}<p class="error" role="alert">{item.error}</p>
{#if !item.inspected}<Button
size="sm"
disabled={busy || inspecting}
onclick={() => inspect(item)}>Retry inspection</Button
>{/if}{/if}
{/if}
</section>
{/each}
</div>
{#if pending.length}
<section class="shared">
<h3>Shared upload details</h3>
<label
>Version notes<textarea
bind:value={notes}
disabled={busy}
rows="3"
placeholder="What changed in these files?"
></textarea></label
>
{#if hasNew}
<Field label="Scope for new scripts">
<Select
bind:value={scopeType}
disabled={busy}
onchange={() => {
scopeGame = '';
scopeMap = '';
}}
><option value="none">Unscoped · choose beta targets</option><option
value="game">Game</option
><option value="map">Map</option></Select
></Field
>
{#if scopeType !== 'none'}
<Field label="Game">
<Select
bind:value={scopeGame}
disabled={busy || !scopeLoaded}
onchange={() => (scopeMap = '')}
><option value="">Choose a game…</option
>{#each scopeOptions.games as g}<option value={g}
>{gameTitle(g)}</option
>{/each}</Select
></Field
>
{#if scopeType === 'map'}<Field label="Map">
<Select bind:value={scopeMap} disabled={busy || !scopeGame}
><option value="">Choose a map…</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option
value={m}>{mapLabel(m)}</option
>{/each}</Select
></Field
>{/if}
{#if scopeValue}<p class="note">
{matches.length} matching servers: {matches
.map((s) => s.name)
.join(', ') || 'None'}
</p>{/if}
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">
Map scope includes every server using this engine map, including its
Survival variants.
</p>{/if}
{:else}<p class="note">
Unscoped scripts deploy only to explicitly selected beta targets.
</p>{/if}
<p class="note">Existing scripts keep their current scope.</p>
{/if}
</section>
{/if}
{#if message}<p class="notice" role="status">{message}</p>{/if}
<footer>
{#if inspecting || busy}<p class="batch-progress" role="status">
{inspecting
? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}`
: `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile
? ` · ${activeFile}`
: ''}
</p>{:else if queue.length}{@const failed = queue.filter((q) => q.error).length}
<p class="batch-progress">
{results.length} saved · {pending.length} remaining{failed
? ` · ${failed} ${plural(failed, 'needs', 'need')} attention`
: ''}
</p>{/if}
{#if pending.length}<Button
disabled={!ready}
loading={busy}
onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button
><Button variant="primary" disabled={!ready} onclick={() => upload(true)}
>Upload & prepare beta →</Button
>
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}
>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button
>{/if}
</footer>
</div>
</dialog>
<style>
dialog {
position: fixed;
inset: 0 0 0 auto;
margin: 0;
width: min(620px, 100vw);
height: 100dvh;
max-height: 100dvh;
max-width: 100vw;
padding: 0;
border: 0;
border-left: 1px solid var(--border-subtle);
background: var(--surface);
color: var(--text-primary);
}
dialog::backdrop {
background: #0009;
backdrop-filter: blur(3px);
}
.drawer {
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
min-height: 100%;
box-sizing: border-box;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
h2 {
margin: 6px 0 0;
font-size: var(--text-2xl);
}
h3 {
margin: 0;
font-size: var(--text-md);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-primary);
font-size: var(--text-xs);
}
.intro,
.note {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
margin: 0;
overflow-wrap: anywhere;
}
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: 28px;
background: var(--surface-sunken);
}
.dropzone span {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.file-input {
display: none;
}
.queue,
.shared {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-item {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.file-heading {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.file-heading strong {
flex: 1;
overflow-wrap: anywhere;
}
.file-heading span {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
label {
display: flex;
flex-direction: column;
gap: 7px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
input,
textarea {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
background: var(--surface-sunken);
font: inherit;
font-size: var(--text-sm);
}
textarea {
resize: vertical;
}
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.success,
.warning,
.error {
margin: 0;
font-size: var(--text-sm);
line-height: 1.5;
}
.success {
color: var(--state-online);
}
.warning {
color: var(--state-warning);
}
.error {
color: var(--state-danger);
}
a {
color: inherit;
}
.notice {
padding: 14px;
background: var(--surface-raised);
border-radius: var(--radius-md);
font-size: var(--text-sm);
line-height: 1.6;
}
footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
margin-top: auto;
padding: 16px 0;
border-top: 1px solid var(--border-subtle);
position: sticky;
bottom: 0;
z-index: 3;
background: var(--surface);
}
.batch-progress {
flex-basis: 100%;
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.drawer {
padding: 20px;
}
}
input:not([type='checkbox']):not([type='file']),
textarea {
border-color: var(--border-control);
}
</style>