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.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
@@ -9,9 +10,14 @@
|
||||
import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte';
|
||||
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, 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);
|
||||
$effect(() => { search; status; game; map; sort; limit = 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('');
|
||||
@@ -34,7 +40,7 @@
|
||||
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 === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
|
||||
&& (!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);
|
||||
@@ -42,6 +48,8 @@
|
||||
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() {
|
||||
@@ -54,7 +62,14 @@
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(() => { void load(); });
|
||||
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';
|
||||
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);
|
||||
@@ -70,6 +85,8 @@
|
||||
}
|
||||
</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} />
|
||||
@@ -78,15 +95,15 @@
|
||||
<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>
|
||||
<div class="status-grid" aria-label="Filter by script status">
|
||||
{#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>
|
||||
</div>{/if}
|
||||
<section class="library" aria-label="Script library">
|
||||
<div class="toolbar">
|
||||
{#if loading || scripts.length || error}<div class="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>
|
||||
@@ -96,7 +113,8 @@
|
||||
<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 search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
|
||||
</div>
|
||||
</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}
|
||||
@@ -134,9 +152,11 @@
|
||||
</section>
|
||||
{#if selectedScripts.length}
|
||||
<div class="selection-bar" role="region" aria-label="Selected scripts">
|
||||
<strong>{selectedScripts.length} selected</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 will be carried into beta setup.</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 variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
|
||||
<Button size="sm" variant="primary" href={betaHref(selectedScripts)}>Test 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>
|
||||
@@ -164,6 +184,9 @@
|
||||
.hint { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 20px; line-height: 1.6; }
|
||||
.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; }
|
||||
@@ -172,4 +195,5 @@
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user