Simplify library lists and game file readiness

This commit is contained in:
2026-10-06 17:09:36 -04:00
parent ad5609cd23
commit a991198d69
10 changed files with 253 additions and 199 deletions
+30 -62
View File
@@ -4,13 +4,13 @@
import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import Badge from '$ui/components/primitives/Badge.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, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
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';
@@ -36,6 +36,7 @@
{ 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 => {
@@ -67,7 +68,7 @@
onMount(() => {
const params = new URLSearchParams(window.location.search);
search = params.get('q') ?? ''; game = params.get('game') ?? ''; map = params.get('map') ?? '';
status = tabs.some(t => t.value === params.get('status')) ? params.get('status') as ScriptStatus : 'all';
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();
@@ -97,23 +98,17 @@
<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>
{#if loading || scripts.length || error}<div class="status-grid" aria-label="Filter by script status">
{#each tabs as tab}
<button class:active={status === tab.value} aria-pressed={status === tab.value} onclick={() => status = tab.value}>
<span>{tab.label}</span><strong>{loading ? '—' : scripts.filter(s => tab.value === 'all' || scriptStatus(s) === tab.value).length}</strong>
</button>
{/each}
</div>{/if}
<section class="library" aria-label="Script library">
{#if loading || scripts.length || error}<div class="toolbar">
{#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">
<label class="select-all"><input type="checkbox" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /> Select visible</label>
<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}
@@ -125,39 +120,31 @@
{:else if !visible.length && !error}
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
{:else}
<div class="rows">
{#each visible.slice(0,limit) as s (s.id)}
{@const state = scriptStatus(s)}
<article class="script-row" class:selected={selected.includes(s.id)}>
<input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} />
<div class="script-info">
<div class="name-row"><a class="name" href={contextHref(`/scripts/${s.id}`, $page.url)}>{s.name}</a><StatusBadge state={state} /></div>
<div class="path">{s.target_rel_path}</div>
<div class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div>
</div>
<div class="versions">
<span>{s.currentVersion ? `Promoted v${s.currentVersion.version}` : 'Never promoted'}</span>
{#if s.latestVersion && s.latestVersion.id !== s.current_version_id}<strong>Latest v{s.latestVersion.version} · {s.latestVersion.state === 'untested' ? 'awaiting test' : s.latestVersion.state.replaceAll('-', ' ')}</strong>{/if}
</div>
<div class="row-action">
{#if s.runningBetaId !== null}<Button size="sm" href={contextHref(`/betas/${s.runningBetaId}`, $page.url)}>View beta →</Button>
{:else if state === 'failed'}<Button size="sm" href={contextHref(`/scripts/${s.id}`, $page.url)}>Review version</Button>
{:else if canTest(s)}<Button size="sm" href={betaHref([s])}>Test version →</Button>
{:else}<Button size="sm" onclick={() => uploader.open(s.id)}>Upload version</Button>{/if}
</div>
</article>
{/each}
</div>
<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" />
<p class="hint">Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.</p>
</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 will be carried into beta setup.</span>
<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)}>Test selected →</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}
@@ -167,23 +154,10 @@
<style>
.page { display: flex; flex-direction: column; gap: var(--space-5); padding-bottom: 90px; }
.status-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--space-2); }
.status-grid button { text-align: left; display: flex; flex-direction: column; gap: 12px; padding: 18px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); cursor: pointer; font: inherit; }
.status-grid button.active { border-color: var(--accent-primary); background: color-mix(in srgb, var(--accent-primary) 8%, var(--surface)); }
.status-grid span { font-size: var(--text-xs); } .status-grid strong { color: var(--text-primary); font-size: var(--text-2xl); }
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; } .search { flex: 1 1 260px; } .toolbar label { min-width: 0; }
input[type='search'], select { width: 100%; min-height: 42px; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
input[type='checkbox'] { accent-color: var(--accent-primary); width: 16px; height: 16px; flex-shrink: 0; }
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: 20px 4px 12px; font-size: var(--text-xs); color: var(--text-secondary); }
.select-all { display: flex; align-items: center; gap: 10px; } .text-button { background: none; border: 0; color: var(--accent-primary); cursor: pointer; font: inherit; }
.rows { display: flex; flex-direction: column; gap: 8px; } .script-row { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(160px, auto) auto; gap: 20px; align-items: center; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.script-row.selected { border-color: var(--accent-primary); } .name-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.name { font-weight: 600; color: var(--text-primary); text-decoration: none; overflow-wrap: anywhere; } .name:hover { color: var(--accent-primary); }
.path { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 6px; overflow-wrap: anywhere; }
.meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 12px; }
.versions { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); } .versions strong { font-weight: 500; color: var(--text-primary); }
.hint { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 20px; line-height: 1.6; }
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; }
@@ -192,10 +166,4 @@
.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; }
@media (max-width: 1100px) { .status-grid { grid-template-columns: repeat(3, 1fr); } .script-row { grid-template-columns: auto minmax(0, 1fr) auto; } .versions { grid-column: 2; } .row-action { grid-column: 3; grid-row: 1 / 3; } }
@media (max-width: 640px) { .status-grid { grid-template-columns: repeat(2, 1fr); } .status-grid button { padding: 12px; gap: 6px; } .status-grid strong { font-size: 24px; } .script-row { gap: 12px; padding: 16px; grid-template-columns: auto minmax(0, 1fr); } .row-action { grid-column: 2; grid-row: auto; } .toolbar label { flex: 1 1 140px; } .toolbar .search { flex-basis: 100%; } .selection-bar span { display: none; } }
.status-grid { display:flex; gap:8px; overflow-x:auto; }
.status-grid button { flex-direction:row; align-items:center; flex-shrink:0; padding:8px 12px; gap:8px; }
.status-grid strong { font-size:13px; }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
</style>