670 lines
26 KiB
Svelte
670 lines
26 KiB
Svelte
<script lang="ts">
|
||
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import { goto } from '$app/navigation';
|
||
import { tick } from 'svelte';
|
||
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;
|
||
release_version: string | null;
|
||
}
|
||
interface Upload {
|
||
key: number;
|
||
file: File;
|
||
target: number | 'new' | '';
|
||
name: string;
|
||
releaseVersion: string;
|
||
duplicates: Duplicate[];
|
||
inspecting: boolean;
|
||
inspected: boolean;
|
||
error: string;
|
||
result:
|
||
(BetaSelection & { version: number; release_version: string; reused: boolean }) | null;
|
||
}
|
||
let dialog: HTMLDialogElement;
|
||
let input: HTMLInputElement;
|
||
let queue = $state<Upload[]>([]);
|
||
let busy = $state(false);
|
||
let notes = $state('');
|
||
let manifest = $state<Record<string, string>>({});
|
||
function applyManifest(versions: Record<string, string>) {
|
||
manifest = versions;
|
||
for (const item of queue) {
|
||
if (!item.result && !item.releaseVersion.trim())
|
||
item.releaseVersion = manifestVersion(manifest, item.file.name);
|
||
}
|
||
}
|
||
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 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;
|
||
})
|
||
);
|
||
|
||
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 = [];
|
||
manifest = {};
|
||
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;
|
||
scopeLoaded = true;
|
||
} 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()
|
||
);
|
||
}
|
||
export async function addFiles(files: File[]) {
|
||
if (busy || inspecting || !files.length) return;
|
||
if (!dialog.open) open();
|
||
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(/\.[^.]+$/, ''),
|
||
releaseVersion: manifestVersion(manifest, file.name),
|
||
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)!);
|
||
preferredTarget = null;
|
||
}
|
||
async function inspect(item: Upload) {
|
||
activeFile = item.file.name;
|
||
item.inspecting = true;
|
||
item.error = '';
|
||
try {
|
||
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 = '';
|
||
}
|
||
}
|
||
function duplicate(item: Upload) {
|
||
return item.duplicates.find(
|
||
(d) => d.scriptId === item.target && d.release_version === item.releaseVersion.trim()
|
||
);
|
||
}
|
||
function validation(item: Upload): string {
|
||
const versionError = scriptVersionError(item.releaseVersion);
|
||
if (versionError) return versionError;
|
||
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.';
|
||
} else {
|
||
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.';
|
||
}
|
||
return '';
|
||
}
|
||
async function upload(prepareBeta: boolean) {
|
||
if (!ready) return;
|
||
busy = true;
|
||
message = '';
|
||
try {
|
||
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('releaseVersion', item.releaseVersion.trim());
|
||
const creating = item.target === 'new';
|
||
if (creating) {
|
||
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 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),
|
||
versionId: creating ? body.versionId : body.version.id,
|
||
version: creating ? 1 : body.version.version,
|
||
release_version: item.releaseVersion.trim(),
|
||
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.';
|
||
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));
|
||
}
|
||
}
|
||
} catch (e) {
|
||
message = (e as Error).message;
|
||
} finally {
|
||
busy = false;
|
||
activeFile = '';
|
||
}
|
||
}
|
||
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>
|
||
<div class="dropzone">
|
||
<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 = '';
|
||
}}
|
||
/>
|
||
</div>
|
||
<ScriptVersionManifest disabled={busy || inspecting} onload={applyManifest} />
|
||
<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>
|
||
{#if item.result}
|
||
<p class="success">
|
||
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as {artifactVersionLabel(
|
||
item.result,
|
||
'script'
|
||
)} ·
|
||
<a href="/scripts/{item.result.scriptId}">View script →</a>
|
||
</p>
|
||
{:else}
|
||
<Field
|
||
label="Release version"
|
||
hint="Required. Use MAJOR.MINOR.PATCH, such as 1.2.0."
|
||
>
|
||
<Input
|
||
bind:value={item.releaseVersion}
|
||
required
|
||
maxlength={80}
|
||
placeholder="1.2.0"
|
||
disabled={busy}
|
||
oninput={() => (item.error = '')}
|
||
/>
|
||
</Field>
|
||
<Field 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></Field
|
||
>
|
||
{#if item.target === 'new'}<Field label="Script name">
|
||
<Input bind:value={item.name} disabled={busy} /></Field
|
||
>
|
||
{: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 {artifactVersionLabel(
|
||
duplicate(item),
|
||
'script'
|
||
)}. 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}
|
||
</section>
|
||
{/each}
|
||
</div>
|
||
{#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
|
||
>
|
||
{#if hasNew}
|
||
<Field 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
|
||
></Field
|
||
>
|
||
{#if scopeType !== 'none'}
|
||
<Field 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
|
||
></Field
|
||
>
|
||
{#if scopeType === 'map'}<Field 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
|
||
></Field
|
||
>{/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}
|
||
</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: 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,
|
||
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,
|
||
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']),
|
||
textarea {
|
||
border-color: var(--border-control);
|
||
}
|
||
</style>
|