Files
electric-cherry/src/lib/ui/components/ScriptUploadDrawer.svelte
T
josh 8ac420fe61 Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements.

Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
2026-09-19 21:34:34 -04:00

218 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 { goto } from '$app/navigation';
import { tick } from 'svelte';
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; }
interface Upload {
key: number; file: File; target: number | 'new' | ''; name: string;
duplicates: Duplicate[]; inspecting: boolean; inspected: boolean; error: string;
result: (BetaSelection & { version: number; reused: boolean }) | null;
}
let dialog: HTMLDialogElement;
let input: HTMLInputElement;
let queue = $state<Upload[]>([]);
let busy = $state(false);
let notes = $state('');
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 = []; 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(/\.[^.]+$/, ''), 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); }
function validation(item: Upload): string {
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());
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, 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>
<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 v{item.result.version} · <a href="/scripts/{item.result.scriptId}">View script →</a></p>
{:else}
<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></label>
{#if item.target === 'new'}<label>Script name<input bind:value={item.name} disabled={busy} /></label>
{: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 v{duplicate(item)!.version}. 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}
<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></label>
{#if scopeType !== 'none'}
<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}>{g.toUpperCase()}</option>{/each}</select></label>
{#if scopeType === 'map'}<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></label>{/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, Grief, or other 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} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} 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: .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, select, 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, select: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']), select, textarea { border-color: var(--border-control); }
</style>