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
+1 -1
View File
@@ -74,7 +74,7 @@
</script>
<section class="inspector" aria-label="Effective config inspector" aria-busy={loading}>
<div class="heading"><div><span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span><h2>{artifactId ? 'On this server' : 'Effective configuration'}</h2></div>
<div class="heading"><div><span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span><h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2></div>
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
</div>
<p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p>
+20 -44
View File
@@ -8,7 +8,8 @@
import { afterNavigate, goto } from '$app/navigation';
import SectionHeader from './primitives/SectionHeader.svelte';
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import LibraryVersions from './LibraryVersions.svelte';
import '$ui/library.css';
import EmptyState from './primitives/EmptyState.svelte';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
@@ -46,8 +47,6 @@
let saving = $state(false);
let createError = $state('');
const baselines = $derived(artifacts.filter(a => a.kind === 'baseline'));
const overlays = $derived(artifacts.filter(a => a.kind === 'config'));
const pending = $derived(artifacts.filter(a => a.latestVersion && a.latestVersion.id !== a.current_version_id).length);
const gameOptions = $derived([...new Set([...scopeOptions.games, ...artifacts.filter(a => a.scope_type === 'game').map(a => a.scope_value!).filter(Boolean)])].sort());
const baselineExists = $derived(kind === 'baseline' && baselines.some(a => a.scope_type === 'game' && a.scope_value === scopeGame));
const selectedScope = $derived(kind === 'baseline' || scopeType === 'game' ? scopeGame : scopeMap);
@@ -106,18 +105,17 @@
</script>
<div class="page">
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes. Inspect the settings applied to a server.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/snippet}
</SectionHeader>
{#if artifacts.length}<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>{/if}
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if view === 'inspect'}
<ConfigInspector />
{:else}
{#if loading || artifacts.length || error}<div class="filters">
<input class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
{#if loading || artifacts.length || error}<div class="library-toolbar">
<input type="search" class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
@@ -129,30 +127,27 @@
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters." />
{:else}
{#if filteredBaselines.length}
<section class="section"><div class="section-heading"><h2>Baselines <span>{filteredBaselines.length}</span></h2><p>One foundation per game</p></div>
<div class="baseline-grid">{#each filteredBaselines as artifact (artifact.id)}
<a class="baseline-card" href={contextHref(`/configs/${artifact.id}`, $page.url)}><div class="card-top"><span class="game-label">{artifact.scope_value?.toUpperCase() ?? 'UNSCOPED'}</span><span class="open-arrow" aria-hidden="true">↗</span></div><h3>{artifact.name}</h3><div class="versions">{@render versionBadges(artifact)}</div><div class="card-footer"><span>{artifact.servers.length} matching server{artifact.servers.length === 1 ? '' : 's'}</span><span>{artifact.versionCount} version{artifact.versionCount === 1 ? '' : 's'}</span></div></a>
{/each}</div>
</section>
<section class="section"><h2>Baselines <span>{filteredBaselines.length}</span></h2>{@render configTable(filteredBaselines)}</section>
{/if}
{#if filteredOverlays.length}
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
<div class="overlay-list">{#each filteredOverlays.slice(0,limit) as artifact (artifact.id)}
<a class="overlay-row" href={contextHref(`/configs/${artifact.id}`, $page.url)}><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
{/each}</div>
</section>
<section class="section"><h2>Overlays <span>{filteredOverlays.length}</span></h2>{@render configTable(filteredOverlays.slice(0,limit))}</section>
{/if}
{/if}
<ListFooter total={filteredOverlays.length} bind:limit label="overlays" />
{#if artifacts.length}<p class="footnote">Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.</p>{/if}
{/if}
</div>
</div>
{#snippet versionBadges(artifact: Artifact)}
{#if artifact.currentVersion}<Badge variant="online" size="sm">v{artifact.currentVersion.version} promoted</Badge>{:else}<Badge variant="neutral" size="sm">Never promoted</Badge>{/if}
{#if artifact.latestVersion && artifact.latestVersion.id !== artifact.current_version_id}<Badge variant={artifact.latestVersion.state === 'test-failed' ? 'danger' : artifact.latestVersion.state === 'tested' ? 'online' : 'neutral'} size="sm">v{artifact.latestVersion.version} {artifact.latestVersion.state.replace('-', ' ')}</Badge>{/if}
{#if artifact.runningBetaId}<Badge variant="warning" size="sm">β{artifact.runningBetaId} active</Badge>{/if}
{#snippet configTable(rows: Artifact[])}
<div class="library-table"><table>
<thead><tr><th>Config</th><th>Scope</th><th>Version / status</th><th>Eligible servers</th></tr></thead>
<tbody>{#each rows as artifact (artifact.id)}<tr>
<td><a class="name" href={contextHref(`/configs/${artifact.id}`, $page.url)}>{artifact.name}</a></td>
<td>{artifact.scope_type === 'game' ? `All ${artifact.scope_value?.toUpperCase()} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
<td><LibraryVersions current={artifact.currentVersion} latest={artifact.latestVersion} betaId={artifact.runningBetaId} /></td>
<td class="numeric">{artifact.servers.length}</td>
</tr>{/each}</tbody>
</table></div>
{/snippet}
<dialog bind:this={dialog} aria-labelledby="new-config-title" oncancel={event => { if (saving) event.preventDefault(); }}>
@@ -175,32 +170,16 @@
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.navigation, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
.navigation, .section-heading, .card-top, .card-footer { justify-content: space-between; }
.navigation { display:flex; align-items:center; gap:12px; justify-content:space-between; }
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
button { background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 14px; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.filters { display: flex; flex-wrap: wrap; gap: 10px; } .search { flex: 1 1 260px; }
input, select { box-sizing: border-box; min-width: 0; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px 12px; font: var(--text-sm) var(--font-body); }
.section { display: flex; flex-direction: column; gap: 14px; }
h2 { font-size: var(--text-md); margin: 0; letter-spacing: var(--tracking-tight); } h2 span { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; margin-left: 8px; }
.section-heading p { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); }
.baseline-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 16px; }
.baseline-card { padding: 24px; background: linear-gradient(140deg, color-mix(in oklab, var(--accent-primary) 7%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); text-decoration: none; color: var(--text-primary); transition: border-color var(--dur-fast); }
.baseline-card:hover, .overlay-row:hover { border-color: var(--accent-primary); }
.game-label { font: 600 var(--text-xs) var(--font-mono); letter-spacing: .08em; color: var(--accent-primary-hover); }
.open-arrow { color: var(--text-secondary); }
h3 { font-size: var(--text-md); margin: 16px 0; font-weight: 600; } .versions { flex-wrap: wrap; gap: 6px; }
.card-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 24px; font-size: var(--text-xs); color: var(--text-secondary); }
.overlay-list { display: flex; flex-direction: column; gap: 8px; }
.overlay-row { text-decoration: none; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 18px 20px; }
.overlay-main { flex: 1; min-width: 0; } .overlay-main h3 { margin: 0 0 5px; font-size: var(--text-sm); }
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: var(--radius-md); background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
.scope-label, .servers { color: var(--text-secondary); font-size: var(--text-xs); } .servers { min-width: 80px; text-align: right; }
.footnote { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
.welcome { padding: 56px 32px; border-radius: var(--radius-lg); background: var(--surface); border: 1px solid var(--border-strong); text-align: center; }
.welcome h2 { margin-top: 12px; font-size: var(--text-2xl); } .welcome p { color: var(--text-secondary); max-width: 540px; margin: 16px auto 24px; }
@@ -218,11 +197,8 @@
label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
.import { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .import input { flex: 1; }
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
@media (max-width: 900px) { .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
@media (max-width: 600px) { .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
@media (max-width:600px) { .form-grid { grid-template-columns:1fr; } form { padding:18px; } .navigation { gap:4px; flex-wrap:wrap; } }
.config-panel { display:grid; gap:20px; }
.baseline-card { padding:16px; border-radius:var(--radius-md); background:var(--surface); }
.card-footer { padding-top:12px; margin-top:12px; }
.welcome { padding:24px; }
.loading { padding:24px 0; }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HostLibraryCache } from '$lib/game-files.js';
import type { Host } from '$lib/hosts.js';
import GameFileTransferProgress from './GameFileTransferProgress.svelte';
import Badge from './primitives/Badge.svelte';
let { caches, hosts, latest = false }: { caches: HostLibraryCache[]; hosts: Host[]; latest?: boolean } = $props();
const healthy = $derived(caches.filter(cache => cache.status === 'ready' && hosts.some(host => host.id === cache.hostId && host.connected) && !cache.error));
const attention = $derived(caches.filter(cache => !healthy.includes(cache)));
function automaticDownloadDetail(host: Host | undefined): string {
if (host?.decommission) return 'Automatic setup is paused during host deletion.';
if (!host?.enrolled) return 'Waiting for host enrollment.';
if (host.mode !== 'active') return 'Automatic setup resumes when the host is active.';
if (!host.credentialActive || !host.connected) return 'Automatic setup resumes when the host reconnects.';
if (host.readiness === 'degraded') return host.readinessReasons[0] ?? 'The host needs attention before automatic setup can continue.';
return 'Download starts automatically after runtime preparation and earlier work finish.';
}
</script>
{#if !hosts.length}<p class="muted"><a href="/hosts">Add a host</a> to prepare these files automatically.</p>{/if}
{#if attention.length}{@render hostTable(attention)}{/if}
{#if healthy.length}<details><summary>Ready hosts ({healthy.length})</summary>{@render hostTable(healthy)}</details>{/if}
{#snippet hostTable(rows: HostLibraryCache[])}
<div class="table-wrap"><table><thead><tr><th>Host</th><th>File status</th><th>Details</th></tr></thead><tbody>
{#each rows as cache (cache.hostId)}
{@const host = hosts.find(host => host.id === cache.hostId)}
{@const waiting = latest && cache.status === 'missing'}
<tr>
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted"> · Offline</span>{/if}</td>
<td><Badge variant={cache.status === 'failed' ? 'danger' : cache.status === 'ready' ? 'online' : 'neutral'}>{waiting ? 'Waiting' : cache.status[0].toUpperCase() + cache.status.slice(1)}</Badge></td>
<td>{#if cache.status === 'ready'}<details><summary>Transfer details</summary><GameFileTransferProgress {cache} connected={host?.connected ?? false} /></details>
{:else if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={host?.connected ?? false} />{/if}
{#if cache.error}<p class="error">{cache.error}</p>{:else if waiting}<p class="muted">{automaticDownloadDetail(host)}</p>{/if}
{#if waiting || cache.status === 'failed'}<a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}
</td>
</tr>
{/each}
</tbody></table></div>
{/snippet}
<style>
.table-wrap { overflow-x:auto; margin-top:10px; }
table { width:100%; min-width:520px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
th,td { padding:10px; border-bottom:1px solid var(--border-subtle); }
th { color:var(--text-secondary); font-weight:500; font-size:var(--text-xs); }
summary { cursor:pointer; font-size:var(--text-sm); }
a { color:var(--accent-primary-hover); } .muted { color:var(--text-secondary); font-size:var(--text-sm); }
p { margin:6px 0; } .error { color:var(--state-danger); }
summary:focus-visible,a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
</style>
@@ -0,0 +1,23 @@
<script lang="ts">
import Badge from './primitives/Badge.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
interface Version { id: number; version: number; release_version?: string | null; state?: string }
let { current, latest, betaId = null }: { current: Version | null; latest: Version | null; betaId?: number | null } = $props();
const newer = $derived(latest && latest.id !== current?.id ? latest : null);
const ambiguous = $derived(!!newer?.release_version && newer.release_version === current?.release_version);
function label(version: Version) { return ambiguous ? artifactVersionLabel(version) : version.release_version || `v${version.version}`; }
const state = $derived(betaId !== null ? 'Beta active' : newer?.state === 'test-failed' ? 'Test failed' : newer?.state === 'tested' ? 'Test passed' : 'Needs testing');
</script>
<div class="versions">
{#if newer}<div><span>{label(newer)}</span><Badge size="sm" variant={state === 'Test failed' ? 'danger' : state === 'Test passed' ? 'online' : 'neutral'}>{state}</Badge></div>{/if}
{#if current}<div class:secondary={!!newer}><span>{label(current)}</span>{#if newer}<span>Promoted</span>{:else}<Badge size="sm" variant="online">Promoted</Badge>{/if}</div>
{:else}<span class="secondary">{latest ? 'Never promoted' : 'No versions'}</span>{/if}
{#if betaId !== null && !newer}<Badge size="sm" variant="neutral">Beta active</Badge>{/if}
</div>
<style>
.versions { display:grid; gap:5px; font-size:var(--text-sm); }
.versions>div { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
.secondary { color:var(--text-secondary); font-size:var(--text-xs); }
</style>
+2 -1
View File
@@ -3,6 +3,7 @@
import type { PluginUpdate } from '$lib/plugin-github.js';
import Card from './primitives/Card.svelte';
import Button from './primitives/Button.svelte';
let { onchecked }: { onchecked?: (updates: PluginUpdate[]) => void } = $props();
let updates = $state<PluginUpdate[]>([]);
let checking = $state(false);
let checked = $state(false);
@@ -15,7 +16,7 @@
const response = await fetch('/api/plugins/updates');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not check updates.');
updates = body.updates; checked = true;
updates = body.updates; checked = true; onchecked?.(updates);
} catch (e) { error = (e as Error).message; }
finally { checking = false; }
}
+19
View File
@@ -0,0 +1,19 @@
.library-toolbar { display:flex; align-items:center; flex-wrap:wrap; gap:10px; }
.library-toolbar .search { flex:1 1 260px; min-width:0; }
.library-toolbar input, .library-toolbar select { box-sizing:border-box; width:100%; min-height:40px; padding:9px 12px; border:1px solid var(--border-control); border-radius:var(--radius-sm); background:var(--surface-sunken); color:var(--text-primary); font:var(--text-sm) var(--font-body); }
.library-toolbar>select { width:auto; }
.library-table { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.library-table table { width:100%; min-width:680px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
.library-table th { padding:10px 16px; color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; background:var(--surface-sunken); }
.library-table td { padding:12px 16px; border-top:1px solid var(--border-subtle); background:var(--surface); vertical-align:middle; }
.library-table tr:hover td { background:var(--surface-raised); }
.library-table .name { color:var(--text-primary); font-weight:600; text-decoration:none; overflow-wrap:anywhere; }
.library-table a { color:var(--accent-primary-hover); }
.library-table .name:hover { color:var(--accent-primary-hover); text-decoration:underline; }
.library-table .secondary { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:5px; overflow-wrap:anywhere; }
.library-table .numeric { font-variant-numeric:tabular-nums; }
.library-table .select-cell { width:20px; padding-right:0; }
.library-table tr.selected td { background:color-mix(in srgb, var(--accent-primary) 8%, var(--surface)); }
.library-table input[type='checkbox'] { width:16px; height:16px; accent-color:var(--accent-primary); }
.library-table a:focus-visible, .library-table input:focus-visible, .library-toolbar :focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
@media(max-width:640px) { .library-toolbar>select, .library-toolbar>label { flex:1 1 140px; } .library-toolbar .search { flex-basis:100%; } }
+14 -34
View File
@@ -11,7 +11,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import GameFileTransferProgress from '$ui/components/GameFileTransferProgress.svelte';
import GameFileHosts from '$ui/components/GameFileHosts.svelte';
type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] };
interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean }
@@ -39,14 +39,6 @@
const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload';
const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)';
const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString();
function automaticDownloadDetail(host: Host | undefined): string {
if (host?.decommission) return 'Automatic setup is paused during host deletion.';
if (!host?.enrolled) return 'Waiting for host enrollment.';
if (host.mode !== 'active') return 'Automatic setup resumes when the host is active.';
if (!host.credentialActive || !host.connected) return 'Automatic setup resumes when the host reconnects.';
if (host.readiness === 'degraded') return host.readinessReasons[0] ?? 'The host needs attention before automatic setup can continue.';
return 'Download starts automatically after runtime preparation and earlier work finish.';
}
async function responseBody(response: Response) {
let body;
@@ -210,7 +202,7 @@
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Game Files" subtitle="Upload your T5 and T6 game folders. Hosts automatically keep a verified copy of the latest files for each game." />
<SectionHeader title="Game Files" subtitle="Verified game files and host readiness." />
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
@@ -218,7 +210,7 @@
{#if canManage && showUpload}
<section class="card" id="game-upload">
<div class="heading"><h2>{phase === 'published' ? `${game.toUpperCase()} files uploaded` : uploadId ? `Continue ${game.toUpperCase()} upload` : `Upload ${game.toUpperCase()} game folder`}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
<p>Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically.</p>
<p>Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically. Running servers keep using their existing files.</p>
{#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
<form onsubmit={startUpload}>
{#if phase !== 'verifying' && phase !== 'published'}
@@ -248,14 +240,12 @@
{@const history = library.versions.filter(item => item.state === 'ready' && item.id !== library.latestVersionId)}
<section class="card">
<div class="heading">
<div><h2>{gameName(library.game)}</h2><p class="muted">{latest ? 'Latest verified game files' : 'Game folder not uploaded yet'}</p></div>
<div><h2>{gameName(library.game)}</h2>{#if latest}<p class="muted">{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files</p>{/if}</div>
{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>{latest ? `Update ${library.game.toUpperCase()} files` : `Upload ${library.game.toUpperCase()} files`}</Button>{/if}
</div>
{#if latest}
<div class="heading"><p>{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files · Uploaded {uploadDate(latest)}</p><Badge variant="online">Available</Badge></div>
<p class="muted">Active hosts keep these files ready automatically, including hosts added later. Running servers keep using their existing files.</p>
{@render hostCopies(latest, true)}
{@render uploadDetails(latest)}
<div class="heading"><span class="muted">Latest verified upload</span><Badge variant={latest.caches.some(cache => cache.status === 'failed') ? 'danger' : latest.caches.length && latest.caches.every(cache => cache.status === 'ready') ? 'online' : 'neutral'}>{latest.caches.length ? `${latest.caches.filter(cache => cache.status === 'ready').length} / ${latest.caches.length} host copies ready` : 'No hosts'}</Badge></div>
<GameFileHosts caches={latest.caches} {hosts} latest />
{:else}
<p class="muted">Upload the complete {library.game.toUpperCase()} game folder once. Hosts will download and verify it automatically as part of setup.</p>
{/if}
@@ -275,6 +265,8 @@
{/each}
</div>
{/if}
{#if latest || history.length}<details class="library-details"><summary>Details</summary><div class="detail-content">
{#if latest}<p class="muted">Uploaded {uploadDate(latest)}</p>{@render uploadDetails(latest)}{@render downloadControls(latest)}{/if}
{#if history.length}
<details class="history">
<summary>Previous uploads ({history.length})</summary>
@@ -285,11 +277,12 @@
{#if item.filesRemoved}<p class="muted">Files removed from controller · upload history retained</p>{:else if item.filesRemoving}<p class="muted">Removing unused controller files · upload history retained</p>{/if}
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p>
{@render uploadDetails(item)}
<details><summary>Host copies</summary>{@render hostCopies(item, false)}</details>
<details><summary>Host copies</summary><GameFileHosts caches={item.caches} {hosts} />{@render downloadControls(item)}</details>
</article>
{/each}
</details>
{/if}
</div></details>{/if}
</section>
{/each}
</div>
@@ -298,25 +291,13 @@
<details><summary>Upload details</summary><dl><dt>Upload ID</dt><dd>{item.id}</dd><dt>Manifest SHA256</dt><dd>{item.manifestSha256 ?? 'Available after verification'}</dd><dt>Uploaded from</dt><dd>{!item.sourceHostId ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}</dd></dl><a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer">View complete file manifest</a></details>
{/snippet}
{#snippet hostCopies(item: OverviewLibrary['versions'][number], latest: boolean)}
{#if !hosts.length}<p class="muted">Files are ready. <a href="/hosts">Add a host</a> to prepare them automatically.</p>{/if}
<div class="table-wrap"><table><thead><tr><th>Host</th><th>File status</th><th>Progress</th><th>Detail</th></tr></thead><tbody>
{#each item.caches as cache}
{@const cacheHost = hosts.find(host => host.id === cache.hostId)}
{@const awaitingAutomaticDownload = latest && cache.status === 'missing'}
<tr>
<td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td>
<td>{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}</td>
<td>{#if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={cacheHost?.connected ?? false} />{:else}—{/if}</td>
<td>{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} <a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}</td>
</tr>
{:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each}
</tbody></table></div>
{#snippet downloadControls(item: OverviewLibrary['versions'][number])}
{#if canManage && !item.filesRemoved && !item.filesRemoving}<details><summary>Advanced: download or verify these files</summary><p class="muted">Retry a download or prepare a previous upload on a host. The latest files are downloaded automatically.</p><div class="download"><label>Host<select bind:value={targets[item.id]}><option value="">Select host</option>{#each onlineHosts as host}<option value={host.id}>{host.name} · {formatGameBytes(Number(host.inventory.storageAvailable ?? 0))} free</option>{/each}</select></label><Button size="sm" disabled={busy || !targets[item.id]} onclick={() => download(item.id)}>Download / verify</Button></div></details>{/if}
{/snippet}
<style>
.page, .card, .uploads, .upload-entry, form { display:grid; gap:20px; }
.page, .card, .uploads, .upload-entry, form { display:grid; gap:16px; }
.detail-content { display:grid; gap:16px; padding-top:16px; }
.card { padding:24px; min-width:0; border:1px solid var(--border-subtle); background:var(--surface); border-radius:var(--radius-md); }
.uploads, .upload-entry { padding-top:20px; border-top:1px solid var(--border-subtle); }
.history>p, .history>.upload-entry { margin-top:16px; }
@@ -330,9 +311,8 @@
.path-list { padding-left:20px; font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; }
.current-file { font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; } progress { width:100%; accent-color:var(--accent-primary); }
h2,h3,p { margin:0; } h2 { font-size:var(--text-md); } h3 { font-size:var(--text-sm); }
p,td,th,details { font-size:var(--text-sm); line-height:1.6; } .muted { color:var(--text-secondary); }
p,details { font-size:var(--text-sm); line-height:1.6; } .muted { color:var(--text-secondary); }
a { color:var(--accent-primary-hover); } summary { cursor:pointer; }
dl { display:grid; grid-template-columns:auto 1fr; gap:6px 16px; } dd { margin:0; overflow-wrap:anywhere; }
.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; text-align:left; } th,td { padding:10px; border-bottom:1px solid var(--border-subtle); }
@media(max-width:640px) { .card,.folder-summary,.upload-progress { padding:16px; } dl { grid-template-columns:1fr; } .heading>div { min-width:0; } }
</style>
+30 -57
View File
@@ -5,14 +5,14 @@
import { afterNavigate } from '$app/navigation';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import LibraryVersions from '$ui/components/LibraryVersions.svelte';
import type { PluginUpdate } from '$lib/plugin-github.js';
import '$ui/library.css';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { mapLabel } from '$lib/script-library.js';
import PluginGithubImport from '$ui/components/PluginGithubImport.svelte';
import PluginUpdates from '$ui/components/PluginUpdates.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
@@ -47,6 +47,7 @@
let importComplete = $state<{ pluginId: number; betaUrl: string; alreadyImported: boolean } | null>(null);
let method = $state('upload');
let updatesOpen = $state(false);
let updates = $state<PluginUpdate[]>([]);
let search = $state('');
let game = $state('');
let status = $state('');
@@ -170,7 +171,7 @@
<div class="page">
<SectionHeader title="Plugins" subtitle="Manage plugin versions and test changes before promotion.">
{#snippet actions()}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
{#snippet actions()}{#if plugins.length}<Button variant="ghost" onclick={() => updatesOpen = !updatesOpen}>{updatesOpen ? 'Hide update details' : 'Check for updates'}</Button>{/if}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
</SectionHeader>
{#if importComplete}<div class="banner" role="status"><strong>{importComplete.alreadyImported ? 'This plugin version was already imported.' : 'Plugin imported.'}</strong><span><a href={'/plugins/' + importComplete.pluginId}>View version</a> · <a href={importComplete.betaUrl}>Start beta</a></span></div>{/if}
{#if successBanner}
@@ -181,48 +182,37 @@
{/if}
{#if flash && !addOpen}<div class="ui-error" role="alert">{flash}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if loading || plugins.length || flash}<div class="ui-toolbar">
<input type="search" aria-label="Search plugins" bind:value={search} placeholder="Search names or files…" />
{#if loading || plugins.length || flash}<div class="library-toolbar">
<input class="search" type="search" aria-label="Search plugins" bind:value={search} placeholder="Search names or files…" />
<select aria-label="Filter plugins by game" bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
<select aria-label="Filter plugins by status" bind:value={status}><option value="">All statuses</option><option value="untested">Needs testing</option><option value="testing">Beta active</option><option value="test-failed">Test failed</option><option value="tested">Test passed</option><option value="promoted">Promoted</option></select>
<select aria-label="Filter plugins by status" bind:value={status}><option value="">All statuses</option><option value="untested">Needs testing</option><option value="testing">Beta active</option><option value="test-failed">Test failed</option><option value="tested">Test passed</option><option value="promoted">Promoted</option><option value="empty">No versions</option></select>
<select aria-label="Sort plugins" bind:value={sort}><option value="name">Name A–Z</option><option value="recent">Recently uploaded</option></select>
</div>{/if}
{#if game}<p class="filter-hint">Game results include unscoped plugins that can use explicitly selected beta servers.</p>{/if}
{#if plugins.length}<details ontoggle={event => updatesOpen = event.currentTarget.open}><summary>Check plugin updates</summary>{#if updatesOpen}{#key importCount}<PluginUpdates />{/key}{/if}</details>{/if}
{#if updatesOpen}<details open ontoggle={event => { if (!event.currentTarget.open) updatesOpen = false; }}><summary>Update check details</summary>{#key importCount}<PluginUpdates onchecked={results => updates = results} />{/key}</details>{/if}
<section class="section">
<div class="section-head">
<h2>All plugins</h2>
<Badge variant="neutral" size="sm">{plugins.length}</Badge>
</div>
<div class="list-heading"><span>{visible.length} of {plugins.length} plugins</span>{#if search || game || status}<Button variant="ghost" size="sm" onclick={() => { search = ''; game = ''; status = ''; }}>Clear filters</Button>{/if}</div>
{#if loading}
<div class="loading">Loading…</div>
{:else if visible.length === 0 && !flash}
<EmptyState title={plugins.length ? "No matching plugins" : "No plugins yet"} description={plugins.length ? "Try different filters." : "Add a plugin to upload a file or import a GitHub release."} />
{:else}
<div class="rows">
{#each visible.slice(0,limit) as a (a.id)}
<a class="row" href={contextHref(`/plugins/${a.id}`, $page.url)}>
<div class="row-top">
<span class="name">{a.name}</span>
<Badge
variant={a.scope_type === 'game' ? 'info' : a.scope_type === 'map' ? 'warning' : 'neutral'}
size="sm"
>
{scopeLabel(a)}
</Badge>
<StatusBadge state={stateLabel(a)} />
</div>
<div class="row-meta">
<span class="path">{a.target_rel_path}</span>
<span>{a.currentVersion ? `Promoted ${artifactVersionLabel(a.currentVersion)}` : 'Never promoted'}</span>
<span>Latest {artifactVersionLabel(a.latestVersion)} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
{#if a.servers.length}
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
{/if}
</div>
</a>
{/each}
</div>
<div class="library-table"><table>
<thead><tr><th>Plugin</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 a (a.id)}
{@const update = updates.find(update => update.pluginId === a.id && update.status === 'available')}
<tr>
<td><a class="name" href={contextHref(`/plugins/${a.id}`, $page.url)}>{a.name}</a>{#if a.target_rel_path !== `${a.name}.dll`}<span class="secondary">{a.target_rel_path}</span>{/if}
{#if update}<span class="secondary">{update.release?.tag} available</span>{/if}</td>
<td>{scopeLabel(a)}</td>
<td><LibraryVersions current={a.currentVersion} latest={a.latestVersion} betaId={a.runningBetaId} /></td>
<td class="numeric">{a.scope_type ? a.servers.length : 'Explicit targets'}</td>
<td>{#if update}<a href={contextHref(`/plugins/${a.id}#github-releases`, $page.url)}>Review update</a>
{:else if a.runningBetaId !== null}<a href={contextHref(`/betas/${a.runningBetaId}`, $page.url)}>View beta</a>
{:else if a.latestVersion && a.latestVersion.id !== a.current_version_id}<a href={contextHref(`/plugins/${a.id}`, $page.url)}>Review version</a>{/if}</td>
</tr>
{/each}</tbody>
</table></div>
<ListFooter total={visible.length} bind:limit label="plugins" />
{/if}
</section>
@@ -231,7 +221,7 @@
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
<Tabs tabs={[{id:'upload',label:'Upload file',disabled:uploading || githubBusy},{id:'github',label:'GitHub release',disabled:uploading || githubBusy}]} bind:value={method} ariaLabel="Plugin source" idPrefix="plugin-source" panelId="plugin-source-panel" />
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
{#if method === 'github'}<PluginGithubImport bind:busy={githubBusy} onimport={async result => { importComplete = result; importCount++; addOpen = false; await load(); }} />
{#if method === 'github'}<PluginGithubImport bind:busy={githubBusy} onimport={async result => { importComplete = result; updates = updates.filter(update => update.pluginId !== result.pluginId); importCount++; addOpen = false; await load(); }} />
{:else} <form onsubmit={createPlugin} class="upload-form">
<Field label="Name">
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
@@ -296,25 +286,8 @@
.flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); }
.section { display: flex; flex-direction: column; gap: var(--space-3); }
.section-head { display: flex; align-items: center; gap: var(--space-3); }
.section-head h2 { margin: 0; font-family: var(--font-body); font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); }
.rows { display: flex; flex-direction: column; gap: var(--space-2); }
.row {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-4) var(--space-5);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary); text-decoration: none;
transition: background-color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.row:hover { background: var(--surface-raised); border-color: var(--border-strong); }
.row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.row-top { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
.name { font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.row-meta { display: flex; gap: var(--space-5); color: var(--text-tertiary); font-family: var(--font-mono); font-size: var(--text-xs); flex-wrap: wrap; }
.path { color: var(--text-secondary); }
.list-heading { display:flex; gap:16px; align-items:center; color:var(--text-secondary); font-size:var(--text-xs); }
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-sm); margin-bottom:12px; }
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
@media (max-width: 720px) { .upload-form { grid-template-columns: 1fr; } }
</style>
+29 -61
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; }
.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>
+63
View File
@@ -0,0 +1,63 @@
import { expect, it } from 'vitest';
import { render } from 'svelte/server';
import LibraryVersions from '../../src/lib/ui/components/LibraryVersions.svelte';
import GameFileHosts from '../../src/lib/ui/components/GameFileHosts.svelte';
import type { HostLibraryCache } from '../../src/lib/game-files.js';
import type { Host } from '../../src/lib/hosts.js';
it('shows the promoted version once and keeps a newer failed version distinct', () => {
const current = { id: 1, version: 1 };
const promoted = render(LibraryVersions, { props: { current, latest: current } }).body;
expect(promoted.match(/v1/g)).toHaveLength(1);
expect(promoted.match(/Promoted/g)).toHaveLength(1);
const newer = render(LibraryVersions, { props: { current, latest: { id: 2, version: 2, state: 'test-failed' } } }).body;
expect(newer).toContain('Test failed');
expect(newer).toContain('v2');
expect(newer).toContain('v1');
expect(newer).toContain('Promoted');
});
it('disambiguates two builds with the same release tag only when needed', () => {
const current = { id: 1, version: 1, release_version: 'v1.1.0' };
expect(render(LibraryVersions, { props: { current, latest: current } }).body).not.toContain('upload');
const body = render(LibraryVersions, { props: { current, latest: { ...current, id: 2, version: 2, state: 'untested' } } }).body;
expect(body).toContain('v1.1.0 (upload 1)');
expect(body).toContain('v1.1.0 (upload 2)');
expect(body).toContain('Needs testing');
});
it('does not present an active beta as passed or an empty artifact as promoted', () => {
expect(render(LibraryVersions, { props: { current: null, latest: null } }).body).toContain('No versions');
const body = render(LibraryVersions, { props: { current: null, latest: { id: 1, version: 1, state: 'tested' }, betaId: 12 } }).body;
expect(body).toContain('Beta active');
expect(body).not.toContain('Test passed');
expect(body).toContain('Never promoted');
});
const host = (id: string, connected = true) => ({ id, name: id, connected, enrolled: true, credentialActive: true, mode: 'active', readiness: 'ready' } as Host);
const cache = (id: string, status: HostLibraryCache['status'], error: string | null = null): HostLibraryCache => ({
hostId: id, libraryVersionId: 'upload', status, error, bytesDownloaded: 100, bytesTotal: 100,
manifestSha256: null, updatedAt: '2026-10-06T12:00:00Z', operationId: null
});
it('surfaces failures, transfers and offline hosts before collapsed healthy copies', () => {
const body = render(GameFileHosts, { props: { latest: true,
hosts: [host('healthy'), host('failed'), host('transfer'), host('offline', false)],
caches: [cache('healthy', 'ready'), cache('failed', 'failed', 'Checksum mismatch'), cache('transfer', 'verifying'), cache('offline', 'ready')]
} }).body;
const ready = body.indexOf('Ready hosts (1)');
expect(body.indexOf('Checksum mismatch')).toBeLessThan(ready);
expect(body.indexOf('Host verification progress')).toBeLessThan(ready);
expect(body.indexOf('Offline')).toBeLessThan(ready);
expect(body.indexOf('href="/hosts/healthy"')).toBeGreaterThan(ready);
expect(body).toMatch(/<details[^>]*><summary[^>]*>Ready hosts \(1\)/);
expect(body).not.toMatch(/<details[^>]*open/);
expect(body).toContain('View host setup');
});
it('explains pending automatic downloads and keeps copy errors visible even for ready status', () => {
const body = render(GameFileHosts, { props: { latest: true, hosts: [host('waiting', false), host('inconsistent')], caches: [cache('waiting', 'missing'), cache('inconsistent', 'ready', 'Host report needs review')] } }).body;
expect(body).toContain('Automatic setup resumes when the host reconnects.');
expect(body).toContain('Host report needs review');
expect(body).not.toContain('Ready hosts');
});