Simplify library lists and game file readiness
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user