170 lines
14 KiB
Svelte
170 lines
14 KiB
Svelte
<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>
|