Refresh script library with batch uploads and beta handoff
This commit is contained in:
+142
-362
@@ -1,387 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.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 { zombieMaps } from '$lib/map-aliases.js';
|
||||
|
||||
interface ScriptRow {
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
||||
latestVersion: {
|
||||
id: number; version: number; sha256: string; size: number;
|
||||
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
||||
} | null;
|
||||
versionCount: number;
|
||||
servers: string[];
|
||||
runningBetaId: number | null;
|
||||
}
|
||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
||||
|
||||
let scripts = $state<ScriptRow[]>([]);
|
||||
let scripts = $state<LibraryScript[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let uploading = $state(false);
|
||||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||||
let successBanner = $state<{ name: string; scriptId: number; version: number } | null>(null);
|
||||
|
||||
let newName = $state('');
|
||||
let newScopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let newScopeGame = $state('');
|
||||
let newScopeMap = $state('');
|
||||
let newFile = $state<File | null>(null);
|
||||
let newNotes = $state('');
|
||||
|
||||
const scopeValue = $derived.by(() => {
|
||||
if (newScopeType === 'game') return newScopeGame;
|
||||
if (newScopeType === 'map') return newScopeMap;
|
||||
return '';
|
||||
});
|
||||
|
||||
let serverCache = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
|
||||
const matchingServers = $derived.by(() => {
|
||||
if (newScopeType === 'none') return null;
|
||||
if (!scopeValue) return null;
|
||||
return serverCache
|
||||
.filter((s) => (newScopeType === 'game' ? s.game : s.map) === scopeValue)
|
||||
.map((s) => s.name);
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [scriptsRes, scopeRes, serversRes] = await Promise.all([
|
||||
fetch('/api/scripts?kind=script'),
|
||||
fetch('/api/scope-options'),
|
||||
fetch('/api/servers')
|
||||
]);
|
||||
const body = (await scriptsRes.json()) as { scripts: ScriptRow[] };
|
||||
scripts = body.scripts;
|
||||
scopeOptions = (await scopeRes.json()) as ScopeOptions;
|
||||
const sBody = (await serversRes.json()) as { servers: Array<{ name: string; game: string | null; map: string | null }> };
|
||||
serverCache = sBody.servers;
|
||||
loading = false;
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
function pickFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
newFile = t.files?.[0] ?? null;
|
||||
if (!newName && newFile) newName = newFile.name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
async function createScript(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (uploading || !newFile || !newName.trim()) return;
|
||||
if (newScopeType !== 'none' && !scopeValue) {
|
||||
flash = `Pick a ${newScopeType}`;
|
||||
return;
|
||||
}
|
||||
uploading = true; flash = null;
|
||||
const form = new FormData();
|
||||
form.append('name', newName.trim());
|
||||
form.append('scopeType', newScopeType);
|
||||
if (scopeValue) form.append('scopeValue', scopeValue);
|
||||
if (newNotes.trim()) form.append('notes', newNotes.trim());
|
||||
form.append('content', newFile);
|
||||
try {
|
||||
const res = await fetch('/api/scripts', { method: 'POST', body: form });
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean; error?: string;
|
||||
script?: { id: number; name: string };
|
||||
versionId?: number;
|
||||
};
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
successBanner = body.script
|
||||
? { name: body.script.name, scriptId: body.script.id, version: 1 }
|
||||
: null;
|
||||
newName = ''; newScopeType = 'none';
|
||||
newScopeGame = ''; newScopeMap = '';
|
||||
newNotes = ''; newFile = null;
|
||||
await load();
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(s: ScriptRow): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
|
||||
}
|
||||
function detailHref(s: ScriptRow): string {
|
||||
if (s.kind === 'config' || s.kind === 'baseline') return `/configs/${s.id}`;
|
||||
if (s.kind === 'plugin') return `/plugins/${s.id}`;
|
||||
return `/scripts/${s.id}`;
|
||||
}
|
||||
function stateVariantFor(s: ScriptRow): 'online' | 'warning' | 'danger' | 'neutral' | null {
|
||||
if (!s.latestVersion) return null;
|
||||
if (s.current_version_id === null) return 'neutral';
|
||||
if (s.latestVersion.id !== s.current_version_id) {
|
||||
if (s.latestVersion.state === 'untested') return 'neutral';
|
||||
if (s.latestVersion.state === 'tested') return 'online';
|
||||
if (s.latestVersion.state === 'test-failed') return 'danger';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function stateLabel(s: ScriptRow): string {
|
||||
if (!s.latestVersion) return '';
|
||||
if (s.current_version_id === null) return 'untested';
|
||||
if (s.latestVersion.id !== s.current_version_id) {
|
||||
return `v${s.latestVersion.version} ${s.latestVersion.state.replace('-', ' ')}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
let gameOptions = $derived([
|
||||
{ value: '', label: '— Pick a game —', disabled: true },
|
||||
...scopeOptions.games.map((g) => ({ value: g, label: g }))
|
||||
]);
|
||||
let mapOptions = $derived([
|
||||
{ value: '', label: '— Pick a map —', disabled: true },
|
||||
...(scopeOptions.mapsByGame[newScopeGame] ?? []).map((m) => ({ value: m, label: m }))
|
||||
]);
|
||||
let scopeTypeOptions = [
|
||||
{ value: 'none', label: 'Unscoped' },
|
||||
{ value: 'game', label: 'Game' },
|
||||
{ value: 'map', label: 'Map' }
|
||||
let error = $state('');
|
||||
let search = $state('');
|
||||
let status = $state<ScriptStatus | 'all'>('all');
|
||||
let game = $state('');
|
||||
let map = $state('');
|
||||
let sort = $state('attention');
|
||||
let selected = $state<number[]>([]);
|
||||
let uploader: ScriptUploadDrawer;
|
||||
let dragDepth = $state(0);
|
||||
const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'All scripts' }, { value: 'untested', label: 'Needs testing' },
|
||||
{ value: 'testing', label: 'Beta active' }, { value: 'failed', label: 'Test failed' },
|
||||
{ value: 'tested', label: 'Test passed' }, { value: 'promoted', label: 'Promoted' }
|
||||
];
|
||||
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 => {
|
||||
const haystack = `${s.name} ${s.target_rel_path} ${s.scope_value ?? ''} ${scopeLabel(s)}`.toLowerCase();
|
||||
return search.toLowerCase().trim().split(/\s+/).every(term => haystack.includes(term))
|
||||
&& (status === 'all' || scriptStatus(s) === status)
|
||||
&& (!game || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
|
||||
&& (!map || s.scope_type === 'map' && s.scope_value === map);
|
||||
}).sort((a, b) => {
|
||||
if (sort === 'recent') return (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') || a.name.localeCompare(b.name);
|
||||
if (sort === 'attention') return priority[scriptStatus(a)] - priority[scriptStatus(b)] || a.name.localeCompare(b.name);
|
||||
return a.name.localeCompare(b.name);
|
||||
}));
|
||||
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
|
||||
const selectable = $derived(visible.filter(canTest));
|
||||
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
|
||||
async function load() {
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch('/api/scripts?kind=script');
|
||||
if (!res.ok) throw new Error('Could not load scripts. Try again.');
|
||||
scripts = (await res.json()).scripts;
|
||||
selected = selected.filter(id => scripts.some(s => s.id === id && canTest(s)));
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(() => { void load(); });
|
||||
function toggle(id: number) { selected = selected.includes(id) ? selected.filter(n => n !== id) : [...selected, id]; }
|
||||
function selectVisible() {
|
||||
const ids = selectable.map(s => s.id);
|
||||
selected = allSelected ? selected.filter(id => !ids.includes(id)) : [...new Set([...selected, ...ids])];
|
||||
}
|
||||
function betaHref(rows: LibraryScript[]) { return betaSelectionHref(rows.map(s => ({ scriptId: s.id, versionId: s.latestVersion!.id }))); }
|
||||
function resetFilters() { search = ''; status = 'all'; game = ''; map = ''; }
|
||||
function onDrag(event: DragEvent) { if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); }
|
||||
function onDrop(event: DragEvent) {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||
event.preventDefault(); dragDepth = 0;
|
||||
void uploader.addFiles([...event.dataTransfer.files]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window ondragover={onDrag} ondrop={onDrop}
|
||||
ondragenter={event => { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }}
|
||||
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Scripts" subtitle="Upload a script, then open a beta to test it before promotion." />
|
||||
|
||||
<!-- Upload form -->
|
||||
<Card padding="lg">
|
||||
<h2 class="panel-title">Upload a script</h2>
|
||||
|
||||
{#if successBanner}
|
||||
<div class="banner">
|
||||
<strong>v{successBanner.version} uploaded — untested.</strong>
|
||||
<span>
|
||||
Open a beta on <a href="/scripts/{successBanner.scriptId}">{successBanner.name}</a>
|
||||
to test it before it can be promoted.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={createScript} class="upload-form">
|
||||
<Field label="Name">
|
||||
<Input bind:value={newName} placeholder="hud tweaks" disabled={uploading} />
|
||||
</Field>
|
||||
|
||||
<Field label="Notes" hint="Optional. What changed in this version.">
|
||||
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
||||
</Field>
|
||||
|
||||
<Field label="Scope" hint="Where this script deploys. Unscoped ships wherever a beta targets it.">
|
||||
<div class="scope-row">
|
||||
<Select bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
|
||||
{#if newScopeType === 'game' || newScopeType === 'map'}
|
||||
<Select bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
|
||||
{/if}
|
||||
{#if newScopeType === 'map'}
|
||||
<Select bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if newScopeType !== 'none' && scopeValue}
|
||||
{@const matches = matchingServers ?? []}
|
||||
{#if matches.length === 0}
|
||||
<div class="preview warn">No matching servers — the promoted script will land nowhere.</div>
|
||||
{:else}
|
||||
<div class="preview ok">
|
||||
{matches.length} matching server{matches.length === 1 ? '' : 's'}: {matches.join(', ')}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<Field label="File">
|
||||
<Input type="file" onchange={pickFile} disabled={uploading} />
|
||||
</Field>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={uploading || !newFile || !newName}
|
||||
loading={uploading}
|
||||
>
|
||||
Upload script
|
||||
</Button>
|
||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<!-- Script list -->
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>All scripts</h2>
|
||||
<Badge variant="neutral" size="sm">{scripts.length}</Badge>
|
||||
<SectionHeader title="Scripts" eyebrow="Your script library" subtitle="Review versions, prepare a beta, and keep releases moving.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="status-grid" aria-label="Filter by script status">
|
||||
{#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>
|
||||
<section class="library" aria-label="Script library">
|
||||
<div class="toolbar">
|
||||
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
|
||||
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
|
||||
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
|
||||
<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>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if scripts.length === 0}
|
||||
<EmptyState title="No scripts yet" description="Upload one above to get started." />
|
||||
<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 search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
|
||||
</div>
|
||||
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
|
||||
{#if loading}<p class="loading" role="status">Loading your library…</p>
|
||||
{:else if !scripts.length && !error}
|
||||
<EmptyState title="Your next release starts here" description="Drop script files anywhere, or upload your first script to prepare a beta." />
|
||||
<div class="empty-action"><Button onclick={() => uploader.open()}>Upload scripts</Button></div>
|
||||
{: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 scripts as s (s.id)}
|
||||
<a class="row" href={detailHref(s)}>
|
||||
<div class="row-top">
|
||||
<span class="name">{s.name}</span>
|
||||
<Badge
|
||||
variant={s.scope_type === 'game' ? 'info' :
|
||||
s.scope_type === 'map' ? 'warning' : 'neutral'}
|
||||
size="sm"
|
||||
>
|
||||
{scopeLabel(s)}
|
||||
</Badge>
|
||||
{#if stateVariantFor(s)}
|
||||
{@const v = stateVariantFor(s)!}
|
||||
<Badge variant={v} size="sm">{stateLabel(s)}</Badge>
|
||||
{/if}
|
||||
{#if s.runningBetaId}
|
||||
<Badge variant="warning" size="sm">β{s.runningBetaId} running</Badge>
|
||||
{/if}
|
||||
{#each visible 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="/scripts/{s.id}">{s.name}</a><Badge variant={state === 'failed' ? 'danger' : state === 'testing' ? 'warning' : state === 'promoted' || state === 'tested' ? 'online' : 'neutral'} size="sm">{statusLabels[state]}</Badge></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="row-meta">
|
||||
<span class="path">{s.target_rel_path}</span>
|
||||
<span>v{s.latestVersion?.version ?? '—'} · {s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span>
|
||||
{#if s.servers.length}
|
||||
<span>{s.servers.length} matching server{s.servers.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
<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>
|
||||
</a>
|
||||
<div class="row-action">
|
||||
{#if s.runningBetaId !== null}<Button size="sm" href="/betas/{s.runningBetaId}">View beta →</Button>
|
||||
{:else if state === 'failed'}<Button size="sm" href="/scripts/{s.id}">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>
|
||||
{/if}
|
||||
<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>{selectedScripts.length} selected</strong><span>Latest versions will be carried into beta setup.</span>
|
||||
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
|
||||
<Button variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dragDepth > 0}<div class="drop-overlay"><strong>Drop your scripts here</strong><span>We’ll recognize existing scripts and check for duplicate versions.</span></div>{/if}
|
||||
<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
background: rgba(124, 212, 255, 0.06);
|
||||
border: 1px solid rgba(124, 212, 255, 0.22);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.banner a { color: var(--state-online); text-decoration: underline; }
|
||||
|
||||
.upload-form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.scope-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
|
||||
.preview {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.preview.ok { background: var(--surface-sunken); color: var(--text-secondary); }
|
||||
.preview.warn { background: rgba(255, 176, 32, 0.06); color: var(--state-warning); border: 1px solid rgba(255, 176, 32, 0.28); }
|
||||
|
||||
.form-actions {
|
||||
flex-wrap: wrap;
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.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); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.upload-form { grid-template-columns: 1fr; }
|
||||
}
|
||||
.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; }
|
||||
.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); }
|
||||
.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; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user