Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+512 -102
View File
@@ -4,12 +4,29 @@
import { plural } from '$ui/plural.js';
import { gameTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte';
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
interface Duplicate { scriptId: number; versionId: number; version: number; }
import {
betaSelectionHref,
mapLabel,
scopeLabel,
type BetaSelection,
type LibraryScript
} from '$lib/script-library.js';
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } =
$props();
interface Duplicate {
scriptId: number;
versionId: number;
version: number;
}
interface Upload {
key: number; file: File; target: number | 'new' | ''; name: string;
duplicates: Duplicate[]; inspecting: boolean; inspected: boolean; error: string;
key: number;
file: File;
target: number | 'new' | '';
name: string;
duplicates: Duplicate[];
inspecting: boolean;
inspected: boolean;
error: string;
result: (BetaSelection & { version: number; reused: boolean }) | null;
}
let dialog: HTMLDialogElement;
@@ -20,153 +37,315 @@
let scopeType = $state<'none' | 'game' | 'map'>('none');
let scopeGame = $state('');
let scopeMap = $state('');
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({
games: [],
mapsByGame: {}
});
let servers = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
let scopeLoaded = $state(false);
let message = $state('');
let preferredTarget = $state<number | null>(null);
let nextKey = 0;
let activeFile = $state('');
const checkedCount = $derived(queue.filter(q => q.inspected).length);
const pending = $derived(queue.filter(q => !q.result));
const inspecting = $derived(queue.some(q => q.inspecting));
const hasNew = $derived(pending.some(q => q.target === 'new'));
const scopeValue = $derived(scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : '');
const matches = $derived(servers.filter(s => scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue));
const ready = $derived(pending.length > 0 && !busy && !inspecting && pending.every(q => q.inspected && !validation(q))
&& (!hasNew || scopeType === 'none' || scopeLoaded && !!scopeValue));
const results = $derived(queue.flatMap(q => q.result ? [q.result] : []));
const testable = $derived(results.filter(r => {
const s = scripts.find(s => s.id === r.scriptId);
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
}));
const checkedCount = $derived(queue.filter((q) => q.inspected).length);
const pending = $derived(queue.filter((q) => !q.result));
const inspecting = $derived(queue.some((q) => q.inspecting));
const hasNew = $derived(pending.some((q) => q.target === 'new'));
const scopeValue = $derived(
scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : ''
);
const matches = $derived(
servers.filter((s) => (scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue))
);
const ready = $derived(
pending.length > 0 &&
!busy &&
!inspecting &&
pending.every((q) => q.inspected && !validation(q)) &&
(!hasNew || scopeType === 'none' || (scopeLoaded && !!scopeValue))
);
const results = $derived(queue.flatMap((q) => (q.result ? [q.result] : [])));
const testable = $derived(
results.filter((r) => {
const s = scripts.find((s) => s.id === r.scriptId);
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
})
);
export function open(scriptId?: number) {
// Reopening preserves unfinished work; a targeted upload starts a fresh review
// only when there is no pending file to lose.
if (!pending.length) { queue = []; notes = ''; message = ''; preferredTarget = scriptId ?? null; }
if (!pending.length) {
queue = [];
notes = '';
message = '';
preferredTarget = scriptId ?? null;
}
dialog.showModal();
if (!scopeLoaded) void loadScope();
}
async function loadScope() {
try {
const [scopeRes, serversRes] = await Promise.all([fetch('/api/scope-options'), fetch('/api/servers')]);
if (!scopeRes.ok || !serversRes.ok) throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
scopeOptions = await scopeRes.json(); servers = (await serversRes.json()).servers;
const [scopeRes, serversRes] = await Promise.all([
fetch('/api/scope-options'),
fetch('/api/servers')
]);
if (!scopeRes.ok || !serversRes.ok)
throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
scopeOptions = await scopeRes.json();
servers = (await serversRes.json()).servers;
scopeLoaded = true;
} catch (e) { message = (e as Error).message; }
} catch (e) {
message = (e as Error).message;
}
}
function matchingScripts(file: File) {
return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase());
return scripts.filter(
(s) =>
s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ===
file.name.toLowerCase()
);
}
export async function addFiles(files: File[]) {
if (busy || inspecting || !files.length) return;
if (!dialog.open) open();
const items = files.map(file => {
const items = files.map((file) => {
const matches = matchingScripts(file);
const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null };
const item: Upload = {
key: nextKey++,
file,
target:
preferredTarget ??
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
name: file.name.replace(/\.[^.]+$/, ''),
duplicates: [],
inspecting: true,
inspected: false,
error: '',
result: null
};
return item;
});
queue = [...queue, ...items];
for (const item of items) await inspect(queue.find(q => q.key === item.key)!);
for (const item of items) await inspect(queue.find((q) => q.key === item.key)!);
preferredTarget = null;
}
async function inspect(item: Upload) {
activeFile = item.file.name;
item.inspecting = true; item.error = '';
item.inspecting = true;
item.error = '';
try {
const form = new FormData(); form.append('content', item.file);
const form = new FormData();
form.append('content', item.file);
const res = await fetch('/api/scripts/inspect', { method: 'POST', body: form });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
item.duplicates = body.duplicates; item.inspected = true;
} catch (e) { item.error = (e as Error).message; }
finally { item.inspecting = false; activeFile = ''; }
item.duplicates = body.duplicates;
item.inspected = true;
} catch (e) {
item.error = (e as Error).message;
} finally {
item.inspecting = false;
activeFile = '';
}
}
function duplicate(item: Upload) {
return item.duplicates.find((d) => d.scriptId === item.target);
}
function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
function validation(item: Upload): string {
if (item.target === '') return 'Several scripts use this filename. Choose which script to update.';
if (item.target === '')
return 'Several scripts use this filename. Choose which script to update.';
if (item.target === 'new') {
if (!item.name.trim()) return 'Give this script a name.';
if (scripts.some(s => s.name === item.name.trim())) return 'This name already exists. Choose a new name or update that script.';
if (queue.some(q => q.key !== item.key && !q.result && q.target === 'new' && q.name.trim() === item.name.trim())) return 'Use a distinct name for each new script.';
if (scripts.some((s) => s.name === item.name.trim()))
return 'This name already exists. Choose a new name or update that script.';
if (
queue.some(
(q) =>
q.key !== item.key &&
!q.result &&
q.target === 'new' &&
q.name.trim() === item.name.trim()
)
)
return 'Use a distinct name for each new script.';
} else {
const script = scripts.find(s => s.id === item.target);
const script = scripts.find((s) => s.id === item.target);
if (!script) return 'This script is no longer available.';
if (script.runningBetaId !== null) return 'Finish the active beta before uploading another version.';
if (queue.some(q => q.key !== item.key && !q.result && q.target === item.target)) return 'Choose one file per existing script in this batch.';
if (script.runningBetaId !== null)
return 'Finish the active beta before uploading another version.';
if (queue.some((q) => q.key !== item.key && !q.result && q.target === item.target))
return 'Choose one file per existing script in this batch.';
}
return '';
}
async function upload(prepareBeta: boolean) {
if (!ready) return;
busy = true; message = '';
busy = true;
message = '';
try {
for (const item of queue.filter(q => !q.result)) {
for (const item of queue.filter((q) => !q.result)) {
activeFile = item.file.name;
item.error = '';
try {
const form = new FormData();
form.append('content', item.file); form.append('notes', notes.trim());
form.append('content', item.file);
form.append('notes', notes.trim());
const creating = item.target === 'new';
if (creating) {
form.append('name', item.name.trim()); form.append('scopeType', scopeType);
form.append('name', item.name.trim());
form.append('scopeType', scopeType);
if (scopeValue) form.append('scopeValue', scopeValue);
} else form.append('reuseExisting', 'true');
const res = await fetch(creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`, { method: 'POST', body: form });
const res = await fetch(
creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`,
{ method: 'POST', body: form }
);
const body = await res.json();
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Upload failed. Try again.');
item.result = { scriptId: creating ? body.script.id : item.target as number,
if (!res.ok || !body.ok)
throw new Error(body.error ?? 'Upload failed. Try again.');
item.result = {
scriptId: creating ? body.script.id : (item.target as number),
versionId: creating ? body.versionId : body.version.id,
version: creating ? 1 : body.version.version, reused: !!body.reused };
} catch (e) { item.error = (e as Error).message; }
version: creating ? 1 : body.version.version,
reused: !!body.reused
};
} catch (e) {
item.error = (e as Error).message;
}
}
await onuploaded();
if (queue.some(q => !q.result)) message = 'Some files need attention. Successful uploads are saved; retry only the remaining files.';
if (queue.some((q) => !q.result))
message =
'Some files need attention. Successful uploads are saved; retry only the remaining files.';
else {
message = 'Your files are ready. Choose servers and a test plan in beta setup.';
// Read from refreshed props after Svelte applies the parent update.
await tick();
if (testable.length !== results.length) message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
if (testable.length !== results.length)
message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
if (prepareBeta && testable.length === results.length && testable.length) {
dialog.close();
await goto(betaSelectionHref(testable));
}
}
} catch (e) { message = (e as Error).message; }
finally { busy = false; activeFile = ''; }
} catch (e) {
message = (e as Error).message;
} finally {
busy = false;
activeFile = '';
}
}
function close(event?: Event) {
if (busy || inspecting) {
event?.preventDefault();
return;
}
dialog.close();
}
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
</script>
<dialog bind:this={dialog} aria-labelledby="upload-title" oncancel={close}>
<div class="drawer">
<header><div><span class="eyebrow">Prepare your next release</span><h2 id="upload-title">Upload scripts</h2></div><Button variant="ghost" ariaLabel="Close upload drawer" disabled={busy || inspecting} onclick={() => close()}>✕</Button></header>
<p class="intro">Drop files anywhere or choose them below. Review each destination before uploading.</p>
<header>
<div>
<span class="eyebrow">Prepare your next release</span>
<h2 id="upload-title">Upload scripts</h2>
</div>
<Button
variant="ghost"
ariaLabel="Close upload drawer"
disabled={busy || inspecting}
onclick={() => close()}>✕</Button
>
</header>
<p class="intro">
Drop files anywhere or choose them below. Review each destination before uploading.
</p>
<div class="dropzone">
<strong>{preferredTarget ? `New version for ${scripts.find(s => s.id === preferredTarget)?.name ?? 'script'}` : 'One file or a whole batch'}</strong>
<strong
>{preferredTarget
? `New version for ${scripts.find((s) => s.id === preferredTarget)?.name ?? 'script'}`
: 'One file or a whole batch'}</strong
>
<span>Existing filenames are recognized automatically.</span>
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button>
<input class="file-input" bind:this={input} type="file" multiple aria-label="Choose script files" onchange={event => { void addFiles([...event.currentTarget.files ?? []]); event.currentTarget.value = ''; }} />
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button
>
<input
class="file-input"
bind:this={input}
type="file"
multiple
aria-label="Choose script files"
onchange={(event) => {
void addFiles([...(event.currentTarget.files ?? [])]);
event.currentTarget.value = '';
}}
/>
</div>
<div class="queue" aria-live="polite">
{#each queue as item (item.key)}
<section class="upload-item" aria-label={item.file.name}>
<div class="file-heading"><strong>{item.file.name}</strong><span>{(item.file.size / 1024).toFixed(1)} KB</span>
<Button variant="ghost" size="sm" disabled={busy || inspecting} ariaLabel={`Remove ${item.file.name}`} onclick={() => queue = queue.filter(q => q.key !== item.key)}>Remove</Button>
<div class="file-heading">
<strong>{item.file.name}</strong><span
>{(item.file.size / 1024).toFixed(1)} KB</span
>
<Button
variant="ghost"
size="sm"
disabled={busy || inspecting}
ariaLabel={`Remove ${item.file.name}`}
onclick={() => (queue = queue.filter((q) => q.key !== item.key))}
>Remove</Button
>
</div>
{#if item.result}
<p class="success">{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result.version} · <a href="/scripts/{item.result.scriptId}">View script →</a></p>
<p class="success">
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result
.version} ·
<a href="/scripts/{item.result.scriptId}">View script →</a>
</p>
{:else}
<label>Destination<select bind:value={item.target} disabled={busy || item.inspecting} onchange={() => item.error = ''}>
<option value="" disabled>Choose a script…</option><option value="new">Create a new script</option>
{#each scripts as s}<option value={s.id}>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null ? ' · beta active' : ''}</option>{/each}
</select></label>
{#if item.target === 'new'}<label>Script name<input bind:value={item.name} disabled={busy} /></label>
{:else if item.target !== ''}{@const s = scripts.find(s => s.id === item.target)}<p class="note">Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}</p>{/if}
<label
>Destination<select
bind:value={item.target}
disabled={busy || item.inspecting}
onchange={() => (item.error = '')}
>
<option value="" disabled>Choose a script…</option><option
value="new">Create a new script</option
>
{#each scripts as s}<option value={s.id}
>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null
? ' · beta active'
: ''}</option
>{/each}
</select></label
>
{#if item.target === 'new'}<label
>Script name<input bind:value={item.name} disabled={busy} /></label
>
{:else if item.target !== ''}{@const s = scripts.find(
(s) => s.id === item.target
)}
<p class="note">
Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}
</p>{/if}
{#if item.inspecting}<p class="note">Checking file contents…</p>
{:else if duplicate(item)}<p class="success">Already uploaded as v{duplicate(item)!.version}. We’ll reuse that version.</p>
{:else if item.target !== 'new' && item.target !== ''}<p class="note">New version for {scripts.find(s => s.id === item.target)?.name}</p>{/if}
{:else if duplicate(item)}<p class="success">
Already uploaded as v{duplicate(item)!.version}. We’ll reuse that
version.
</p>
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
New version for {scripts.find((s) => s.id === item.target)?.name}
</p>{/if}
{#if validation(item)}<p class="warning">{validation(item)}</p>{/if}
{#if item.error}<p class="error" role="alert">{item.error}</p>{#if !item.inspected}<Button size="sm" disabled={busy || inspecting} onclick={() => inspect(item)}>Retry inspection</Button>{/if}{/if}
{#if item.error}<p class="error" role="alert">{item.error}</p>
{#if !item.inspected}<Button
size="sm"
disabled={busy || inspecting}
onclick={() => inspect(item)}>Retry inspection</Button
>{/if}{/if}
{/if}
</section>
{/each}
@@ -174,46 +353,277 @@
{#if pending.length}
<section class="shared">
<h3>Shared upload details</h3>
<label>Version notes<textarea bind:value={notes} disabled={busy} rows="3" placeholder="What changed in these files?"></textarea></label>
<label
>Version notes<textarea
bind:value={notes}
disabled={busy}
rows="3"
placeholder="What changed in these files?"
></textarea></label
>
{#if hasNew}
<label>Scope for new scripts<select bind:value={scopeType} disabled={busy} onchange={() => { scopeGame = ''; scopeMap = ''; }}><option value="none">Unscoped · choose beta targets</option><option value="game">Game</option><option value="map">Map</option></select></label>
<label
>Scope for new scripts<select
bind:value={scopeType}
disabled={busy}
onchange={() => {
scopeGame = '';
scopeMap = '';
}}
><option value="none">Unscoped · choose beta targets</option><option
value="game">Game</option
><option value="map">Map</option></select
></label
>
{#if scopeType !== 'none'}
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{gameTitle(g)}</option>{/each}</select></label>
{#if scopeType === 'map'}<label>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}><option value="">Choose a map…</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>{/if}
{#if scopeValue}<p class="note">{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}</p>{/if}
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">Map scope includes every server using this engine map, including its Survival variants.</p>{/if}
{:else}<p class="note">Unscoped scripts deploy only to explicitly selected beta targets.</p>{/if}
<label
>Game<select
bind:value={scopeGame}
disabled={busy || !scopeLoaded}
onchange={() => (scopeMap = '')}
><option value="">Choose a game…</option
>{#each scopeOptions.games as g}<option value={g}
>{gameTitle(g)}</option
>{/each}</select
></label
>
{#if scopeType === 'map'}<label
>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}
><option value="">Choose a map…</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option
value={m}>{mapLabel(m)}</option
>{/each}</select
></label
>{/if}
{#if scopeValue}<p class="note">
{matches.length} matching servers: {matches
.map((s) => s.name)
.join(', ') || 'None'}
</p>{/if}
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">
Map scope includes every server using this engine map, including its
Survival variants.
</p>{/if}
{:else}<p class="note">
Unscoped scripts deploy only to explicitly selected beta targets.
</p>{/if}
<p class="note">Existing scripts keep their current scope.</p>
{/if}
</section>
{/if}
{#if message}<p class="notice" role="status">{message}</p>{/if}
<footer>
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}` : `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}{@const failed = queue.filter(q => q.error).length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{failed ? ` · ${failed} ${plural(failed, 'needs', 'need')} attention` : ''}</p>{/if}
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
{#if inspecting || busy}<p class="batch-progress" role="status">
{inspecting
? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}`
: `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile
? ` · ${activeFile}`
: ''}
</p>{:else if queue.length}{@const failed = queue.filter((q) => q.error).length}
<p class="batch-progress">
{results.length} saved · {pending.length} remaining{failed
? ` · ${failed} ${plural(failed, 'needs', 'need')} attention`
: ''}
</p>{/if}
{#if pending.length}<Button
disabled={!ready}
loading={busy}
onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button
><Button variant="primary" disabled={!ready} onclick={() => upload(true)}
>Upload & prepare beta →</Button
>
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}
>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button
>{/if}
</footer>
</div>
</dialog>
<style>
dialog { position: fixed; inset: 0 0 0 auto; margin: 0; width: min(620px, 100vw); height: 100dvh; max-height: 100dvh; max-width: 100vw; padding: 0; border: 0; border-left: 1px solid var(--border-subtle); background: var(--surface); color: var(--text-primary); }
dialog::backdrop { background: #0009; backdrop-filter: blur(3px); }
.drawer { padding: 28px; display: flex; flex-direction: column; gap: 20px; min-height: 100%; box-sizing: border-box; }
header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { margin: 6px 0 0; font-size: var(--text-2xl); } h3 { margin: 0; font-size: var(--text-md); }
.eyebrow { text-transform: uppercase; letter-spacing: .1em; color: var(--accent-primary); font-size: var(--text-xs); }
.intro, .note { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; margin: 0; overflow-wrap: anywhere; }
.dropzone { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 28px; background: var(--surface-sunken); }
.dropzone span { font-size: var(--text-xs); color: var(--text-secondary); } .file-input { display: none; }
.queue, .shared { display: flex; flex-direction: column; gap: 16px; } .upload-item { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.file-heading { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .file-heading strong { flex: 1; overflow-wrap: anywhere; } .file-heading span { color: var(--text-tertiary); font-size: var(--text-xs); }
label { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }
input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); background: var(--surface-sunken); font: inherit; font-size: var(--text-sm); } textarea { resize: vertical; }
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.success, .warning, .error { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .success { color: var(--state-online); } .warning { color: var(--state-warning); } .error { color: var(--state-danger); } a { color: inherit; }
.notice { padding: 14px; background: var(--surface-raised); border-radius: var(--radius-md); font-size: var(--text-sm); line-height: 1.6; }
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding: 16px 0; border-top: 1px solid var(--border-subtle); position: sticky; bottom: 0; z-index: 3; background: var(--surface); }
.batch-progress { flex-basis: 100%; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
@media (max-width: 640px) { .drawer { padding: 20px; } }
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
dialog {
position: fixed;
inset: 0 0 0 auto;
margin: 0;
width: min(620px, 100vw);
height: 100dvh;
max-height: 100dvh;
max-width: 100vw;
padding: 0;
border: 0;
border-left: 1px solid var(--border-subtle);
background: var(--surface);
color: var(--text-primary);
}
dialog::backdrop {
background: #0009;
backdrop-filter: blur(3px);
}
.drawer {
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
min-height: 100%;
box-sizing: border-box;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
h2 {
margin: 6px 0 0;
font-size: var(--text-2xl);
}
h3 {
margin: 0;
font-size: var(--text-md);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-primary);
font-size: var(--text-xs);
}
.intro,
.note {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
margin: 0;
overflow-wrap: anywhere;
}
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: 28px;
background: var(--surface-sunken);
}
.dropzone span {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.file-input {
display: none;
}
.queue,
.shared {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-item {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.file-heading {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.file-heading strong {
flex: 1;
overflow-wrap: anywhere;
}
.file-heading span {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
label {
display: flex;
flex-direction: column;
gap: 7px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
input,
select,
textarea {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
background: var(--surface-sunken);
font: inherit;
font-size: var(--text-sm);
}
textarea {
resize: vertical;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.success,
.warning,
.error {
margin: 0;
font-size: var(--text-sm);
line-height: 1.5;
}
.success {
color: var(--state-online);
}
.warning {
color: var(--state-warning);
}
.error {
color: var(--state-danger);
}
a {
color: inherit;
}
.notice {
padding: 14px;
background: var(--surface-raised);
border-radius: var(--radius-md);
font-size: var(--text-sm);
line-height: 1.6;
}
footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
margin-top: auto;
padding: 16px 0;
border-top: 1px solid var(--border-subtle);
position: sticky;
bottom: 0;
z-index: 3;
background: var(--surface);
}
.batch-progress {
flex-basis: 100%;
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.drawer {
padding: 20px;
}
}
input:not([type='checkbox']):not([type='file']),
select,
textarea {
border-color: var(--border-control);
}
</style>