208 lines
17 KiB
Svelte
208 lines
17 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { contextHref } from '$lib/navigation-context.js';
|
|
import { onMount } from 'svelte';
|
|
import Tabs from './primitives/Tabs.svelte';
|
|
import ListFooter from './primitives/ListFooter.svelte';
|
|
import { mapLabel } from '$lib/script-library.js';
|
|
import { afterNavigate, goto } from '$app/navigation';
|
|
import SectionHeader from './primitives/SectionHeader.svelte';
|
|
import Button from './primitives/Button.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';
|
|
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
|
import { gameTitle } from '$ui/server-labels.js';
|
|
|
|
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
|
|
interface Artifact {
|
|
id: number; name: string; kind: 'baseline' | 'config'; scope_type: 'game' | 'map' | null;
|
|
scope_value: string | null; current_version_id: number | null; currentVersion: Version | null;
|
|
latestVersion: Version | null; versionCount: number; servers: string[]; runningBetaId: number | null;
|
|
}
|
|
let artifacts = $state<Artifact[]>([]);
|
|
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let query = $state('');
|
|
let game = $state('');
|
|
let scope = $state('');
|
|
let status = $state('');
|
|
let view = $state('library');
|
|
let limit = $state(25);
|
|
let mounted = $state(false);
|
|
afterNavigate(() => { mounted = true; });
|
|
let lastFilters = '';
|
|
$effect(() => { const key = JSON.stringify([query, game, scope, status]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
|
|
$effect(() => { if (mounted) saveLibraryContext({ q: query, game, scope, status, view, limit }, { view: 'library', limit: 25 }); });
|
|
let dialog: HTMLDialogElement;
|
|
let kind = $state<'baseline' | 'config'>('config');
|
|
let name = $state('');
|
|
let scopeType = $state<'game' | 'map'>('game');
|
|
let scopeGame = $state('');
|
|
let scopeMap = $state('');
|
|
let text = $state('');
|
|
let notes = $state('');
|
|
let saving = $state(false);
|
|
let createError = $state('');
|
|
const baselines = $derived(artifacts.filter(a => a.kind === 'baseline'));
|
|
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);
|
|
const filtered = $derived(artifacts.filter(a => {
|
|
if (query && !`${a.name} ${a.scope_value ?? ''} ${a.servers.join(' ')}`.toLowerCase().includes(query.toLowerCase())) return false;
|
|
if (game && !(a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? ''))) return false;
|
|
if (scope && a.scope_type !== scope) return false;
|
|
if (status === 'pending' && (!a.latestVersion || a.latestVersion.id === a.current_version_id)) return false;
|
|
if (status === 'promoted' && !a.current_version_id) return false;
|
|
if (status === 'beta' && !a.runningBetaId) return false;
|
|
if (status === 'failed' && a.latestVersion?.state !== 'test-failed') return false;
|
|
return true;
|
|
}));
|
|
const filteredBaselines = $derived(filtered.filter(a => a.kind === 'baseline').sort((a, b) => (a.scope_value ?? '').localeCompare(b.scope_value ?? '')));
|
|
const filteredOverlays = $derived(filtered.filter(a => a.kind === 'config'));
|
|
|
|
async function load() {
|
|
loading = true; error = '';
|
|
try {
|
|
const responses = await Promise.all([fetch('/api/configs?kind=baseline'), fetch('/api/configs?kind=config'), fetch('/api/scope-options')]);
|
|
if (responses.some(r => !r.ok)) throw new Error('Could not load the config library. Please try again.');
|
|
const [base, overlay, scopes] = await Promise.all(responses.map(r => r.json()));
|
|
artifacts = [...base.scripts, ...overlay.scripts]; scopeOptions = scopes;
|
|
} catch (err) { error = (err as Error).message; }
|
|
finally { loading = false; }
|
|
}
|
|
onMount(() => { const params = new URLSearchParams(window.location.search); query = params.get('q') ?? ''; game = params.get('game') ?? ''; scope = params.get('scope') ?? ''; status = params.get('status') ?? ''; view = params.get('view') === 'inspect' ? 'inspect' : 'library'; limit = libraryLimit(params); void load(); });
|
|
function openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); }
|
|
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
|
|
async function importFile(event: Event) {
|
|
const input = event.currentTarget as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (file) {
|
|
try { text = await file.text(); if (!name) name = file.name.replace(/\.[^.]+$/, ''); }
|
|
catch { createError = 'Could not read that file.'; }
|
|
}
|
|
input.value = '';
|
|
}
|
|
async function create(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (saving || baselineExists || !name.trim() || !selectedScope || !text.trim()) return;
|
|
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) { createError = 'Add at least one set KEY VALUE line.'; return; }
|
|
saving = true; createError = '';
|
|
const form = new FormData();
|
|
form.append('name', name.trim()); form.append('kind', kind);
|
|
form.append('scopeType', kind === 'baseline' ? 'game' : scopeType); form.append('scopeValue', selectedScope);
|
|
form.append('text', text); if (notes.trim()) form.append('notes', notes.trim());
|
|
try {
|
|
const response = await fetch('/api/configs', { method: 'POST', body: form });
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error ?? 'Could not create this config.');
|
|
dialog.close(); await goto(`/configs/${body.script.id}`);
|
|
} catch (err) { createError = (err as Error).message; }
|
|
finally { saving = false; }
|
|
}
|
|
</script>
|
|
|
|
<div class="page">
|
|
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
|
|
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/if}{/snippet}
|
|
</SectionHeader>
|
|
<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="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}>{gameTitle(option)}</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>
|
|
{#if filtered.length && (query || game || scope || status)}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
|
</div>{/if}
|
|
{#if loading}<div class="loading" role="status">Loading your config library…</div>
|
|
{:else if !artifacts.length && !error}
|
|
<EmptyState title="No configs yet" description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions.">
|
|
{#snippet actions()}<Button variant="primary" onclick={() => openCreate('baseline')}>Create baseline</Button>{/snippet}
|
|
</EmptyState>
|
|
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters.">
|
|
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button>{/snippet}
|
|
</EmptyState>
|
|
{:else}
|
|
{#if filteredBaselines.length}
|
|
<section class="section"><h2>Baselines <span>{filteredBaselines.length}</span></h2>{@render configTable(filteredBaselines)}</section>
|
|
{/if}
|
|
{#if filteredOverlays.length}
|
|
<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}
|
|
</div>
|
|
</div>
|
|
|
|
{#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 ${gameTitle(artifact.scope_value)} 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(); }}>
|
|
<form onsubmit={create}>
|
|
<div class="dialog-heading"><div><span class="eyebrow">Create version 1</span><h2 id="new-config-title">New config</h2></div><Button variant="ghost" onclick={() => dialog.close()} disabled={saving} ariaLabel="Close new config">✕</Button></div>
|
|
<div class="kind-picker"><button type="button" class:active={kind === 'config'} aria-pressed={kind === 'config'} onclick={() => kind = 'config'} disabled={saving}><strong>Overlay</strong><span>Override selected settings</span></button><button type="button" class:active={kind === 'baseline'} aria-pressed={kind === 'baseline'} onclick={() => kind = 'baseline'} disabled={saving}><strong>Baseline</strong><span>A complete config for a game</span></button></div>
|
|
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select></label>
|
|
{#if kind === 'config'}<label>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}><option value="game">Whole game</option><option value="map">One map</option></select></label>{#if scopeType === 'map'}<label>Map<select aria-label="Map" bind:value={scopeMap} required disabled={saving || !scopeGame}><option value="" disabled>Choose a map</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option value={option}>{option}</option>{/each}</select></label>{/if}{/if}
|
|
</div>
|
|
{#if baselineExists}<p class="warning">This game already has a baseline. Open it from the library to save a new version.</p>{/if}
|
|
{#if !gameOptions.length}<p class="warning">Register a server first to make its game and map available here.</p>{/if}
|
|
<label class="import">Start from a file <input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving} /></label>
|
|
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
|
|
<label class="notes">Notes<input bind:value={notes} placeholder="What is this config for?" disabled={saving} /></label>
|
|
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
|
|
<div class="dialog-actions"><p>Saved as untested. Test in a beta before promotion.</p><Button variant="ghost" onclick={() => dialog.close()} disabled={saving}>Cancel</Button><Button type="submit" variant="primary" loading={saving} disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button></div>
|
|
</form>
|
|
</dialog>
|
|
|
|
<style>
|
|
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
|
|
|
.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; }
|
|
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; }
|
|
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
|
|
.eyebrow { color: var(--accent-primary-hover); font-size: var(--text-xs); }
|
|
.error { color: var(--state-danger); padding: 12px; background: var(--surface-raised); border-radius: var(--radius-sm); font-size: var(--text-sm); }
|
|
.warning { color: var(--state-warning); font-size: var(--text-sm); }
|
|
dialog { box-sizing: border-box; width: min(860px, calc(100vw - 32px)); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); }
|
|
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(4px); }
|
|
form { padding: 28px; display: flex; flex-direction: column; gap: 20px; }
|
|
.dialog-heading, .dialog-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .dialog-heading h2 { margin-top: 6px; font-size: var(--text-xl); }
|
|
.kind-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
.kind-picker button { padding: 16px; border: 1px solid var(--border-strong); text-align: left; }
|
|
.kind-picker button.active { border-color: var(--accent-primary); } .kind-picker span { display: block; margin: 6px 0 0; font-size: var(--text-xs); color: var(--text-secondary); }
|
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
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:600px) { .form-grid { grid-template-columns:1fr; } form { padding:18px; } .navigation { gap:4px; flex-wrap:wrap; } }
|
|
.config-panel { display:grid; gap:20px; }
|
|
.loading { padding:24px 0; }
|
|
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
|
</style>
|