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

170 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 { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import LibraryVersions from '$ui/components/LibraryVersions.svelte';
import '$ui/library.css';
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 ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte';
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
import { zombieMaps } from '$lib/map-aliases.js';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
let limit = $state(25);
let mounted = $state(false);
afterNavigate(() => { mounted = true; });
let lastFilters = '';
$effect(() => { const key = JSON.stringify([search, status, game, map, sort]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
$effect(() => { if (mounted) saveLibraryContext({ q: search, status, game, map, sort, limit, selected: selected.join(',') }, { status: 'all', sort: 'attention', limit: 25 }); });
let scripts = $state<LibraryScript[]>([]);
let loading = $state(true);
let error = $state('');
let search = $state('');
let status = $state<ScriptStatus | 'all'>('all');
let game = $state('');
let map = $state('');
let sort = $state('attention');
let selected = $state<number[]>([]);
let uploader: ScriptUploadDrawer;
let dragDepth = $state(0);
const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [
{ value: 'all', label: 'All scripts' }, { value: 'untested', label: 'Needs testing' },
{ value: 'testing', label: 'Beta active' }, { value: 'failed', label: 'Test failed' },
{ value: 'tested', label: 'Test passed' }, { value: 'promoted', label: 'Promoted' }
];
const failedCount = $derived(scripts.filter(script => scriptStatus(script) === 'failed').length);
const priority = { failed: 0, untested: 1, testing: 2, tested: 3, promoted: 4, empty: 5 };
const maps = $derived([...new Set(scripts.filter(s => s.scope_type === 'map' && (!game || zombieMaps(game).some(m => m.canonical === s.scope_value))).map(s => s.scope_value!))].sort());
const visible = $derived(scripts.filter(s => {
const haystack = `${s.name} ${s.target_rel_path} ${s.scope_value ?? ''} ${scopeLabel(s)}`.toLowerCase();
return search.toLowerCase().trim().split(/\s+/).every(term => haystack.includes(term))
&& (status === 'all' || scriptStatus(s) === status)
&& (!game || !s.scope_type || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
&& (!map || s.scope_type === 'map' && s.scope_value === map);
}).sort((a, b) => {
if (sort === 'recent') return (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') || a.name.localeCompare(b.name);
if (sort === 'attention') return priority[scriptStatus(a)] - priority[scriptStatus(b)] || a.name.localeCompare(b.name);
return a.name.localeCompare(b.name);
}));
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
const hiddenSelected = $derived(selectedScripts.filter(s => !visible.slice(0, limit).some(row => row.id === s.id)).length);
let reviewSelection = $state(false);
const selectable = $derived(visible.slice(0,limit).filter(canTest));
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
async function load() {
error = '';
try {
const res = await fetch('/api/scripts?kind=script');
if (!res.ok) throw new Error('Could not load scripts. Try again.');
scripts = (await res.json()).scripts;
selected = selected.filter(id => scripts.some(s => s.id === id && canTest(s)));
} catch (e) { error = (e as Error).message; }
finally { loading = false; }
}
onMount(() => {
const params = new URLSearchParams(window.location.search);
search = params.get('q') ?? ''; game = params.get('game') ?? ''; map = params.get('map') ?? '';
status = [...tabs, { value: 'empty' }].some(t => t.value === params.get('status')) ? params.get('status') as ScriptStatus : 'all';
sort = ['attention', 'recent', 'name'].includes(params.get('sort') ?? '') ? params.get('sort')! : 'attention';
limit = libraryLimit(params); selected = (params.get('selected') ?? '').split(',').map(Number).filter(id => Number.isSafeInteger(id) && id > 0).slice(0, 500);
void load();
});
function toggle(id: number) { selected = selected.includes(id) ? selected.filter(n => n !== id) : [...selected, id]; }
function selectVisible() {
const ids = selectable.map(s => s.id);
selected = allSelected ? selected.filter(id => !ids.includes(id)) : [...new Set([...selected, ...ids])];
}
function betaHref(rows: LibraryScript[]) { return betaSelectionHref(rows.map(s => ({ scriptId: s.id, versionId: s.latestVersion!.id }))); }
function resetFilters() { search = ''; status = 'all'; game = ''; map = ''; }
function onDrag(event: DragEvent) { if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); }
function onDrop(event: DragEvent) {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault(); dragDepth = 0;
void uploader.addFiles([...event.dataTransfer.files]);
}
</script>
<svelte:head><title>Scripts · Electric Cherry</title></svelte:head>
<svelte:window ondragover={onDrag} ondrop={onDrop}
ondragenter={event => { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }}
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
<div class="page">
<SectionHeader title="Scripts" subtitle="Review versions, prepare a beta, and keep releases moving.">
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
</SectionHeader>
<section class="library" aria-label="Script library">
{#if loading || scripts.length || error}<div class="library-toolbar">
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
<label><span class="sr-only">Filter by status</span><select bind:value={status}>{#each tabs as tab}<option value={tab.value}>{tab.value === 'all' ? 'All statuses' : tab.label}</option>{/each}<option value="empty">No versions</option></select></label>
<label><span class="sr-only">Sort scripts</span><select bind:value={sort}><option value="attention">Needs attention first</option><option value="recent">Recently uploaded</option><option value="name">Name A–Z</option></select></label>
</div>
<div class="list-heading">
<span>{visible.length} of {scripts.length} scripts</span>
{#if failedCount && status !== 'failed'}<button class="text-button" onclick={() => { resetFilters(); status = 'failed'; }}>{failedCount} test{failedCount === 1 ? '' : 's'} failed</button>{/if}
{#if search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
</div>{/if}
{#if game}<p class="hint">Game results include unscoped scripts that can use explicitly selected beta servers.</p>{/if}
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
{#if loading}<p class="loading" role="status">Loading your library…</p>
{:else if !scripts.length && !error}
<EmptyState title="Your next release starts here" description="Drop script files anywhere, or upload your first script to prepare a beta." />
<div class="empty-action"><Button onclick={() => uploader.open()}>Upload scripts</Button></div>
{:else if !visible.length && !error}
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
{:else}
<div class="library-table"><table>
<thead><tr><th class="select-cell"><input type="checkbox" aria-label="Select visible scripts eligible for a beta" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /></th><th>Script</th><th>Game / scope</th><th>Version / status</th><th>Eligible servers</th><th><span class="sr-only">Next action</span></th></tr></thead>
<tbody>{#each visible.slice(0,limit) as s (s.id)}
<tr class:selected={selected.includes(s.id)}>
<td class="select-cell"><input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} /></td>
<td><a class="name" href={contextHref(`/scripts/${s.id}`, $page.url)}>{s.name}</a>{#if s.target_rel_path !== `${s.name}.gsc`}<span class="secondary">{s.target_rel_path}</span>{/if}</td>
<td>{scopeLabel(s)}</td>
<td><LibraryVersions current={s.currentVersion} latest={s.latestVersion} betaId={s.runningBetaId} /></td>
<td class="numeric">{s.scope_type ? s.servers.length : 'Explicit targets'}</td>
<td>{#if s.runningBetaId !== null}<a href={contextHref(`/betas/${s.runningBetaId}`, $page.url)}>View beta</a>
{:else if scriptStatus(s) === 'failed'}<a href={contextHref(`/scripts/${s.id}`, $page.url)}>Review failure</a>
{:else if canTest(s)}<a href={betaHref([s])}>Create beta</a>{/if}</td>
</tr>
{/each}</tbody>
</table></div>
{/if}
<ListFooter total={visible.length} bind:limit label="scripts" />
</section>
{#if selectedScripts.length}
<div class="selection-bar" role="region" aria-label="Selected scripts">
<strong title={hiddenSelected ? 'Some selected scripts are outside the current filters or loaded rows.' : undefined}>{selectedScripts.length} selected{hiddenSelected ? ` · ${hiddenSelected} hidden` : ''}</strong><span>Latest versions selected</span>
<Button size="sm" variant="ghost" ariaLabel="Review selected scripts" onclick={() => reviewSelection = !reviewSelection}>Review</Button>
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
<Button size="sm" variant="primary" href={betaHref(selectedScripts)}>Create beta with {selectedScripts.length} selected</Button>
{#if reviewSelection}<ul class="selection-review">{#each selectedScripts as script}<li>{script.name} · v{script.latestVersion?.version}<Button size="sm" variant="ghost" onclick={() => toggle(script.id)} ariaLabel={`Remove ${script.name} from selection`}>Remove</Button></li>{/each}</ul>{/if}
</div>
{/if}
</div>
{#if dragDepth > 0}<div class="drop-overlay"><strong>Drop your scripts here</strong><span>We’ll recognize existing scripts and check for duplicate versions.</span></div>{/if}
<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />
<style>
.page { display: flex; flex-direction: column; gap: var(--space-5); padding-bottom: 90px; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.list-heading { display:flex; flex-wrap:wrap; gap:18px; align-items:center; padding:14px 0; font-size:var(--text-xs); color:var(--text-secondary); }
.text-button { background:none; border:0; color:var(--accent-primary); cursor:pointer; font:inherit; }
.hint { font-size:var(--text-xs); color:var(--text-secondary); }
.selection-bar { position: sticky; bottom: 20px; display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--accent-primary); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: 0 8px 32px #0005; }
.selection-bar span { flex: 1; color: var(--text-secondary); font-size: var(--text-sm); }
.selection-review { flex-basis: 100%; margin: 0; padding: 0; list-style: none; max-height: 240px; overflow: auto; }
.selection-review li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 6px 0; }
@media (max-width: 640px) { .selection-bar { padding: 12px; gap: 8px; bottom: 12px; } .selection-bar > strong { flex-basis: 100%; font-size: var(--text-sm); } }
.drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); }
.drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
</style>