Files
electric-cherry/src/lib/ui/components/ConfigLibrary.svelte
T

230 lines
21 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 Badge from './primitives/Badge.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.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 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);
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. Inspect the settings applied to a server.">
{#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…" />
<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>
{#if 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}
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
{: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>
{/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>
{/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}
<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' : 'T5 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}>{option.toUpperCase()}</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, .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 { 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; }
.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: 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; } }
.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); }
</style>