Use shared form controls and enforce UI patterns (Refs #110)
CI / checks (push) Successful in 4m51s
CI / checks (push) Successful in 4m51s
This commit is contained in:
@@ -8,7 +8,18 @@ Primitives live in its `primitives` directory.
|
|||||||
- Use `SectionHeader` for page titles and primary actions. The page owns spacing
|
- Use `SectionHeader` for page titles and primary actions. The page owns spacing
|
||||||
between sections.
|
between sections.
|
||||||
- Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors.
|
- Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors.
|
||||||
Give controls distinct `ariaLabel` values when several share one field.
|
Give controls distinct `ariaLabel` values when several share one field. Use
|
||||||
|
`hiddenLabel` on `Field` for compact filters with a visually hidden label.
|
||||||
|
`Input` and `Select` forward native validation, accessibility and event props;
|
||||||
|
use `bind:element` when a workflow needs to focus the native control. `Select`
|
||||||
|
accepts either `options` or child `option` / `optgroup` elements and preserves
|
||||||
|
the bound value's type. Keep layout on the field or its parent; controls own
|
||||||
|
their appearance.
|
||||||
|
- `npm run check:styles` rejects raw selects and text-like inputs outside their
|
||||||
|
primitives, including search, password, numeric and date/time fields. Native
|
||||||
|
checkboxes, radios, file pickers, hidden inputs, range/color pickers and input
|
||||||
|
buttons are allowed. The check parses Svelte markup, so comments and examples
|
||||||
|
in scripts are ignored.
|
||||||
- Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists.
|
- Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists.
|
||||||
- Use `StatusBadge` for workflow states and `ServerIdentity` for server names.
|
- Use `StatusBadge` for workflow states and `ServerIdentity` for server names.
|
||||||
Keep raw IDs available for copying and troubleshooting.
|
Keep raw IDs available for copying and troubleshooting.
|
||||||
|
|||||||
+96
-21
@@ -1,32 +1,107 @@
|
|||||||
import { readdir, readFile } from 'node:fs/promises';
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { parse } from 'svelte/compiler';
|
||||||
|
|
||||||
const root = fileURLToPath(new URL('../src/', import.meta.url));
|
const nativeInputTypes = new Set([
|
||||||
const failures = [];
|
'checkbox',
|
||||||
async function visit(directory) {
|
'radio',
|
||||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
'file',
|
||||||
const file = path.join(directory, entry.name);
|
'hidden',
|
||||||
if (entry.isDirectory()) await visit(file);
|
'range',
|
||||||
else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') {
|
'color',
|
||||||
const source = await readFile(file, 'utf8');
|
'button',
|
||||||
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
|
'submit',
|
||||||
const line = source.slice(0, match.index).split('\n').length;
|
'reset',
|
||||||
|
'image'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} source
|
||||||
|
* @param {string} filename
|
||||||
|
*/
|
||||||
|
export function checkFormControls(source, filename) {
|
||||||
|
if (
|
||||||
|
!filename.endsWith('.svelte') ||
|
||||||
|
/(?:^|\/)primitives\/(?:Input|Select)\.svelte$/.test(filename)
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
/** @type {string[]} */
|
||||||
|
const failures = [];
|
||||||
|
/** @param {unknown} node */
|
||||||
|
function visit(node) {
|
||||||
|
if (!node || typeof node !== 'object') return;
|
||||||
|
if ('type' in node && node.type === 'RegularElement') {
|
||||||
|
const element = /** @type {import('svelte/compiler').AST.RegularElement} */ (node);
|
||||||
|
const type = element.attributes.find(
|
||||||
|
(attribute) => attribute.type === 'Attribute' && attribute.name === 'type'
|
||||||
|
);
|
||||||
|
const value =
|
||||||
|
type?.type === 'Attribute' && type.value !== true
|
||||||
|
? Array.isArray(type.value)
|
||||||
|
? type.value[0]
|
||||||
|
: type.value
|
||||||
|
: undefined;
|
||||||
|
const inputType =
|
||||||
|
value?.type === 'Text'
|
||||||
|
? value.data
|
||||||
|
: value?.expression.type === 'Literal'
|
||||||
|
? value.expression.value
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
element.name === 'select' ||
|
||||||
|
(element.name === 'input' &&
|
||||||
|
(typeof inputType !== 'string' || !nativeInputTypes.has(inputType)))
|
||||||
|
) {
|
||||||
|
const line = source.slice(0, element.start).split('\n').length;
|
||||||
failures.push(
|
failures.push(
|
||||||
`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}`
|
`${filename}:${line}: use Field with ${element.name === 'select' ? 'Select' : 'Input'} instead of raw <${element.name}>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const value of Object.values(node)) {
|
||||||
|
if (Array.isArray(value)) value.forEach(visit);
|
||||||
|
else if (value && typeof value === 'object') visit(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
visit(parse(source, { modern: true }).fragment);
|
||||||
|
return failures;
|
||||||
}
|
}
|
||||||
await visit(root);
|
|
||||||
const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8');
|
async function main() {
|
||||||
for (const match of tokens.matchAll(
|
const root = fileURLToPath(new URL('../src/', import.meta.url));
|
||||||
/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g
|
/** @type {string[]} */
|
||||||
)) {
|
const failures = [];
|
||||||
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`);
|
/** @param {string} directory */
|
||||||
|
async function visit(directory) {
|
||||||
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||||
|
const file = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) await visit(file);
|
||||||
|
else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') {
|
||||||
|
const source = await readFile(file, 'utf8');
|
||||||
|
const filename = path.relative(process.cwd(), file);
|
||||||
|
failures.push(...checkFormControls(source, filename));
|
||||||
|
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
|
||||||
|
const line = source.slice(0, match.index).split('\n').length;
|
||||||
|
failures.push(
|
||||||
|
`${filename}:${line}: use a shared type token (12 px minimum): ${match[0]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit(root);
|
||||||
|
const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8');
|
||||||
|
for (const match of tokens.matchAll(
|
||||||
|
/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g
|
||||||
|
)) {
|
||||||
|
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`);
|
||||||
|
}
|
||||||
|
if (failures.length) {
|
||||||
|
console.error(failures.join('\n'));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else console.log('UI styles: shared form controls, font sizes and 12 px minimum verified.');
|
||||||
}
|
}
|
||||||
if (failures.length) {
|
|
||||||
console.error(failures.join('\n'));
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
||||||
process.exitCode = 1;
|
await main();
|
||||||
} else console.log('UI styles: shared font sizes and 12 px minimum verified.');
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -187,59 +190,59 @@
|
|||||||
<h2 class="sr-only" id={headingId}>Activity history</h2>
|
<h2 class="sr-only" id={headingId}>Activity history</h2>
|
||||||
{#snippet filterFields(suffix: string)}
|
{#snippet filterFields(suffix: string)}
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label for={`activity-type${suffix}`}
|
<Field forId={`activity-type${suffix}`} label="Type">
|
||||||
>Type<select
|
<Select
|
||||||
id={`activity-type${suffix}`}
|
id={`activity-type${suffix}`}
|
||||||
value={filters.type ?? ''}
|
value={filters.type ?? ''}
|
||||||
onchange={(event) => update({ type: event.currentTarget.value })}
|
onchange={(event) => update({ type: event.currentTarget.value })}
|
||||||
><option value="">All types</option>{#each typeOptions as [value, label]}<option
|
><option value="">All types</option>{#each typeOptions as [value, label]}<option
|
||||||
{value}>{label}</option
|
{value}>{label}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label for={`activity-state${suffix}`}
|
<Field forId={`activity-state${suffix}`} label="State">
|
||||||
>State<select
|
<Select
|
||||||
id={`activity-state${suffix}`}
|
id={`activity-state${suffix}`}
|
||||||
value={filters.state ?? ''}
|
value={filters.state ?? ''}
|
||||||
onchange={(event) => update({ state: event.currentTarget.value })}
|
onchange={(event) => update({ state: event.currentTarget.value })}
|
||||||
><option value="">All states</option
|
><option value="">All states</option
|
||||||
>{#each Object.entries(activityStates) as [value, label]}<option {value}
|
>{#each Object.entries(activityStates) as [value, label]}<option {value}
|
||||||
>{label}</option
|
>{label}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{#if source === undefined}<label for={`activity-source${suffix}`}
|
{#if source === undefined}<Field forId={`activity-source${suffix}`} label="Source">
|
||||||
>Source<select
|
<Select
|
||||||
id={`activity-source${suffix}`}
|
id={`activity-source${suffix}`}
|
||||||
value={filters.source ?? ''}
|
value={filters.source ?? ''}
|
||||||
onchange={(event) => update({ source: event.currentTarget.value })}
|
onchange={(event) => update({ source: event.currentTarget.value })}
|
||||||
><option value="">All sources</option
|
><option value="">All sources</option
|
||||||
>{#each sourceOptions as [value, label]}<option {value}>{label}</option
|
>{#each sourceOptions as [value, label]}<option {value}>{label}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>{/if}
|
>{/if}
|
||||||
{#if server === undefined}<label for={`activity-server${suffix}`}
|
{#if server === undefined}<Field forId={`activity-server${suffix}`} label="Server">
|
||||||
>Server<select
|
<Select
|
||||||
id={`activity-server${suffix}`}
|
id={`activity-server${suffix}`}
|
||||||
value={filters.server ?? ''}
|
value={filters.server ?? ''}
|
||||||
onchange={(event) => update({ server: event.currentTarget.value })}
|
onchange={(event) => update({ server: event.currentTarget.value })}
|
||||||
><option value="">All servers</option>{#each serverOptions as name}<option
|
><option value="">All servers</option>{#each serverOptions as name}<option
|
||||||
value={name}>{serverTitle(name)}</option
|
value={name}>{serverTitle(name)}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>{/if}
|
>{/if}
|
||||||
<label for={`activity-host${suffix}`}
|
<Field forId={`activity-host${suffix}`} label="Host">
|
||||||
>Host<select
|
<Select
|
||||||
id={`activity-host${suffix}`}
|
id={`activity-host${suffix}`}
|
||||||
value={filters.host ?? ''}
|
value={filters.host ?? ''}
|
||||||
onchange={(event) => update({ host: event.currentTarget.value })}
|
onchange={(event) => update({ host: event.currentTarget.value })}
|
||||||
><option value="">All hosts</option>{#each hostOptions as [value, label]}<option
|
><option value="">All hosts</option>{#each hostOptions as [value, label]}<option
|
||||||
{value}>{label}</option
|
{value}>{label}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label for={`activity-days${suffix}`}
|
<Field forId={`activity-days${suffix}`} label="Date range">
|
||||||
>Date range<select
|
<Select
|
||||||
id={`activity-days${suffix}`}
|
id={`activity-days${suffix}`}
|
||||||
value={String(filters.days ?? 0)}
|
value={String(filters.days ?? 0)}
|
||||||
onchange={(event) => update({ days: event.currentTarget.value })}
|
onchange={(event) => update({ days: event.currentTarget.value })}
|
||||||
@@ -249,8 +252,8 @@
|
|||||||
><option value="90">Last 90 days</option
|
><option value="90">Last 90 days</option
|
||||||
>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option
|
>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option
|
||||||
value={String(filters.days)}>Last {filters.days} days</option
|
value={String(filters.days)}>Last {filters.days} days</option
|
||||||
>{/if}</select
|
>{/if}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="search"
|
class="search"
|
||||||
@@ -260,14 +263,14 @@
|
|||||||
submitSearch();
|
submitSearch();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label for={`activity-search${suffix}`}
|
<Field forId={`activity-search${suffix}`} label="Search activity">
|
||||||
>Search activity<input
|
<Input
|
||||||
id={`activity-search${suffix}`}
|
id={`activity-search${suffix}`}
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
maxlength="200"
|
maxlength={200}
|
||||||
placeholder="Server, host, or activity…"
|
placeholder="Server, host, or activity…"
|
||||||
/></label
|
/></Field
|
||||||
><Button type="submit" size="sm">Search</Button>
|
><Button type="submit" size="sm">Search</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -377,15 +380,15 @@
|
|||||||
? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}`
|
? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}`
|
||||||
: 'No activities on this page'}</span
|
: 'No activities on this page'}</span
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Per page">
|
||||||
>Per page<select
|
<Select
|
||||||
aria-label="Activities per page"
|
aria-label="Activities per page"
|
||||||
value={data.limit}
|
value={data.limit}
|
||||||
onchange={(event) => update({ limit: event.currentTarget.value })}
|
onchange={(event) => update({ limit: event.currentTarget.value })}
|
||||||
>{#each [...new Set( [25, 50, 100, data.limit] )].sort((a, b) => a - b) as size}<option
|
>{#each [...new Set( [25, 50, 100, data.limit] )].sort((a, b) => a - b) as size}<option
|
||||||
value={size}>{size}</option
|
value={size}>{size}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages">
|
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages">
|
||||||
<Button
|
<Button
|
||||||
@@ -418,25 +421,7 @@
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
select,
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.search {
|
.search {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -444,7 +429,8 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.search label {
|
|
||||||
|
.search :global(.field) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.context,
|
.context,
|
||||||
@@ -549,15 +535,7 @@
|
|||||||
.pagination {
|
.pagination {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.pagination label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.pagination select {
|
|
||||||
width: auto;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
.pagination nav {
|
.pagination nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import Dialog from './primitives/Dialog.svelte';
|
import Dialog from './primitives/Dialog.svelte';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
@@ -89,34 +91,34 @@
|
|||||||
</p>
|
</p>
|
||||||
<form onsubmit={add}>
|
<form onsubmit={add}>
|
||||||
<fieldset disabled={busy}>
|
<fieldset disabled={busy}>
|
||||||
<label
|
<Field label="Friendly name">
|
||||||
>Friendly name<input
|
<Input
|
||||||
required
|
required
|
||||||
maxlength="200"
|
maxlength={200}
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder="Game host 1"
|
placeholder="Game host 1"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<details>
|
<details>
|
||||||
<summary>Advanced settings</summary>
|
<summary>Advanced settings</summary>
|
||||||
<div class="ports">
|
<div class="ports">
|
||||||
<label
|
<Field label="First game port">
|
||||||
>First game port<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
bind:value={portMin}
|
bind:value={portMin}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Last game port">
|
||||||
>Last game port<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
min={portMin}
|
min={portMin}
|
||||||
max="65535"
|
max="65535"
|
||||||
bind:value={portMax}
|
bind:value={portMax}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -150,12 +152,7 @@
|
|||||||
.muted {
|
.muted {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
textarea {
|
textarea {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -186,7 +183,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
input:focus-visible,
|
|
||||||
textarea:focus-visible,
|
textarea:focus-visible,
|
||||||
summary:focus-visible {
|
summary:focus-visible {
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import {
|
import {
|
||||||
builtInPlans,
|
builtInPlans,
|
||||||
@@ -192,9 +195,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if savedPresets.length}
|
{#if savedPresets.length}
|
||||||
<label class="saved-picker"
|
<Field class="saved-picker" label="Or use a saved preset">
|
||||||
>Or use a saved preset
|
<Select
|
||||||
<select
|
|
||||||
value={savedPresets.some((p) => p.name === matchingPreset?.name)
|
value={savedPresets.some((p) => p.name === matchingPreset?.name)
|
||||||
? matchingPreset?.name
|
? matchingPreset?.name
|
||||||
: ''}
|
: ''}
|
||||||
@@ -203,8 +205,8 @@
|
|||||||
<option value="" disabled>Choose a saved plan…</option>
|
<option value="" disabled>Choose a saved plan…</option>
|
||||||
{#each savedPresets as preset}<option value={preset.name}>{preset.name}</option
|
{#each savedPresets as preset}<option value={preset.name}>{preset.name}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select>
|
</Select>
|
||||||
</label>
|
</Field>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
|
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
|
||||||
@@ -274,22 +276,21 @@
|
|||||||
<summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary>
|
<summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary>
|
||||||
<div class="editor-fields">
|
<div class="editor-fields">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label
|
<Field label="Description">
|
||||||
>Description<input
|
<Input
|
||||||
id={`plan-${r.id}-label`}
|
id={`plan-${r.id}-label`}
|
||||||
aria-invalid={!!fieldError(`${r.id}-label`)}
|
aria-invalid={!!fieldError(`${r.id}-label`)}
|
||||||
aria-describedby={`error-${r.id}-label`}
|
aria-describedby={`error-${r.id}-label`}
|
||||||
bind:value={r.label}
|
bind:value={r.label}
|
||||||
maxlength="200"
|
maxlength={200}
|
||||||
required
|
required
|
||||||
/><span class="error" id={`error-${r.id}-label`}
|
/><span class="error" id={`error-${r.id}-label`}
|
||||||
>{fieldError(`${r.id}-label`) ?? ''}</span
|
>{fieldError(`${r.id}-label`) ?? ''}</span
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{#if r.kind !== 'manual'}
|
{#if r.kind !== 'manual'}
|
||||||
<label
|
<Field label={targetLabel(r.kind)}>
|
||||||
>{targetLabel(r.kind)}
|
<Input
|
||||||
<input
|
|
||||||
id={`plan-${r.id}-target`}
|
id={`plan-${r.id}-target`}
|
||||||
aria-invalid={!!fieldError(`${r.id}-target`)}
|
aria-invalid={!!fieldError(`${r.id}-target`)}
|
||||||
aria-describedby={`error-${r.id}-target`}
|
aria-describedby={`error-${r.id}-target`}
|
||||||
@@ -318,11 +319,11 @@
|
|||||||
<span class="error" id={`error-${r.id}-target`}
|
<span class="error" id={`error-${r.id}-target`}
|
||||||
>{fieldError(`${r.id}-target`) ?? ''}</span
|
>{fieldError(`${r.id}-target`) ?? ''}</span
|
||||||
>
|
>
|
||||||
</label>
|
</Field>
|
||||||
{/if}
|
{/if}
|
||||||
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
|
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
|
||||||
<label
|
<Field label="Minimum players">
|
||||||
>Minimum players<input
|
<Input
|
||||||
id={`plan-${r.id}-minPlayers`}
|
id={`plan-${r.id}-minPlayers`}
|
||||||
aria-invalid={!!fieldError(`${r.id}-minPlayers`)}
|
aria-invalid={!!fieldError(`${r.id}-minPlayers`)}
|
||||||
aria-describedby={`error-${r.id}-minPlayers`}
|
aria-describedby={`error-${r.id}-minPlayers`}
|
||||||
@@ -333,12 +334,12 @@
|
|||||||
required
|
required
|
||||||
/><span class="error" id={`error-${r.id}-minPlayers`}
|
/><span class="error" id={`error-${r.id}-minPlayers`}
|
||||||
>{fieldError(`${r.id}-minPlayers`) ?? ''}</span
|
>{fieldError(`${r.id}-minPlayers`) ?? ''}</span
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if r.kind === 'matches'}
|
{#if r.kind === 'matches'}
|
||||||
<label
|
<Field label="Minimum round in the same game">
|
||||||
>Minimum round in the same game<input
|
<Input
|
||||||
id={`plan-${r.id}-minRound`}
|
id={`plan-${r.id}-minRound`}
|
||||||
aria-invalid={!!fieldError(`${r.id}-minRound`)}
|
aria-invalid={!!fieldError(`${r.id}-minRound`)}
|
||||||
aria-describedby={`error-${r.id}-minRound`}
|
aria-describedby={`error-${r.id}-minRound`}
|
||||||
@@ -349,12 +350,12 @@
|
|||||||
required
|
required
|
||||||
/><span class="error" id={`error-${r.id}-minRound`}
|
/><span class="error" id={`error-${r.id}-minRound`}
|
||||||
>{fieldError(`${r.id}-minRound`) ?? ''}</span
|
>{fieldError(`${r.id}-minRound`) ?? ''}</span
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if r.kind === 'matches'}
|
{#if r.kind === 'matches'}
|
||||||
<label
|
<Field label="Minimum match duration (minutes)">
|
||||||
>Minimum match duration (minutes)<input
|
<Input
|
||||||
id={`plan-${r.id}-minMinutes`}
|
id={`plan-${r.id}-minMinutes`}
|
||||||
aria-invalid={!!fieldError(`${r.id}-minMinutes`)}
|
aria-invalid={!!fieldError(`${r.id}-minMinutes`)}
|
||||||
aria-describedby={`error-${r.id}-minMinutes`}
|
aria-describedby={`error-${r.id}-minMinutes`}
|
||||||
@@ -365,7 +366,7 @@
|
|||||||
required
|
required
|
||||||
/><span class="error" id={`error-${r.id}-minMinutes`}
|
/><span class="error" id={`error-${r.id}-minMinutes`}
|
||||||
>{fieldError(`${r.id}-minMinutes`) ?? ''}</span
|
>{fieldError(`${r.id}-minMinutes`) ?? ''}</span
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -402,12 +403,12 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="add-row">
|
<div class="add-row">
|
||||||
<label
|
<Field label="Add another check">
|
||||||
>Add another check<select bind:value={addKind}
|
<Select bind:value={addKind}
|
||||||
>{#each requirementKinds as kind}<option value={kind}
|
>{#each requirementKinds as kind}<option value={kind}
|
||||||
>{requirementLabels[kind]}</option
|
>{requirementLabels[kind]}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -434,8 +435,8 @@
|
|||||||
Servers step.
|
Servers step.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<label
|
<Field label="Qualifying servers">
|
||||||
>Qualifying servers<select bind:value={plan.scope}>
|
<Select bind:value={plan.scope}>
|
||||||
<option value="all">Every selected server</option><option
|
<option value="all">Every selected server</option><option
|
||||||
value="quorum">At least N selected servers</option
|
value="quorum">At least N selected servers</option
|
||||||
>
|
>
|
||||||
@@ -445,10 +446,10 @@
|
|||||||
<option value="map-game"
|
<option value="map-game"
|
||||||
>At least one per selected game/map combination</option
|
>At least one per selected game/map combination</option
|
||||||
>
|
>
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
{#if plan.scope === 'quorum'}<label
|
{#if plan.scope === 'quorum'}<Field label="Required servers">
|
||||||
>Required servers<input
|
<Input
|
||||||
id="plan-quorum"
|
id="plan-quorum"
|
||||||
aria-invalid={!!fieldError('quorum')}
|
aria-invalid={!!fieldError('quorum')}
|
||||||
aria-describedby="error-quorum"
|
aria-describedby="error-quorum"
|
||||||
@@ -461,11 +462,11 @@
|
|||||||
/><span class="error" id="error-quorum"
|
/><span class="error" id="error-quorum"
|
||||||
>{fieldError('quorum') ??
|
>{fieldError('quorum') ??
|
||||||
`${serverCount ?? 0} servers selected`}</span
|
`${serverCount ?? 0} servers selected`}</span
|
||||||
></label
|
></Field
|
||||||
>{/if}
|
>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<label
|
<Field label="Servers per wave (0 = all at once)">
|
||||||
>Servers per wave (0 = all at once)<input
|
<Input
|
||||||
id="plan-waveSize"
|
id="plan-waveSize"
|
||||||
aria-invalid={!!fieldError('waveSize')}
|
aria-invalid={!!fieldError('waveSize')}
|
||||||
aria-describedby="error-waveSize"
|
aria-describedby="error-waveSize"
|
||||||
@@ -476,7 +477,7 @@
|
|||||||
required
|
required
|
||||||
/><span class="error" id="error-waveSize"
|
/><span class="error" id="error-waveSize"
|
||||||
>{fieldError('waveSize') ?? ''}</span
|
>{fieldError('waveSize') ?? ''}</span
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
@@ -496,8 +497,8 @@
|
|||||||
>
|
>
|
||||||
<div class="policy-body">
|
<div class="policy-body">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label
|
<Field label="Testing window (hours from opening)">
|
||||||
>Testing window (hours from opening)<input
|
<Input
|
||||||
id="plan-timeoutHours"
|
id="plan-timeoutHours"
|
||||||
aria-invalid={!!fieldError('timeoutHours')}
|
aria-invalid={!!fieldError('timeoutHours')}
|
||||||
aria-describedby="error-timeoutHours"
|
aria-describedby="error-timeoutHours"
|
||||||
@@ -509,20 +510,20 @@
|
|||||||
required
|
required
|
||||||
/><span class="error" id="error-timeoutHours"
|
/><span class="error" id="error-timeoutHours"
|
||||||
>{fieldError('timeoutHours') ?? ''}</span
|
>{fieldError('timeoutHours') ?? ''}</span
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="On test failure">
|
||||||
>On test failure<select bind:value={plan.failureAction}
|
<Select bind:value={plan.failureAction}
|
||||||
><option value="fail">Mark failed and hold</option><option value="pause"
|
><option value="fail">Mark failed and hold</option><option value="pause"
|
||||||
>Pause for investigation</option
|
>Pause for investigation</option
|
||||||
><option value="rollback">Roll back automatically</option></select
|
><option value="rollback">Roll back automatically</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Rollback activation">
|
||||||
>Rollback activation<select bind:value={plan.rollbackRestart}
|
<Select bind:value={plan.rollbackRestart}
|
||||||
><option value="when-empty">Restore and restart when empty</option
|
><option value="when-empty">Restore and restart when empty</option
|
||||||
><option value="manual">Hold changes for manual resume</option></select
|
><option value="manual">Hold changes for manual resume</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
@@ -541,12 +542,12 @@
|
|||||||
><span class="edit-hint">Save</span></summary
|
><span class="edit-hint">Save</span></summary
|
||||||
>
|
>
|
||||||
<div class="policy-body fields">
|
<div class="policy-body fields">
|
||||||
<label
|
<Field label="Preset name">
|
||||||
>Preset name<input
|
<Input
|
||||||
bind:value={presetName}
|
bind:value={presetName}
|
||||||
maxlength="80"
|
maxlength={80}
|
||||||
placeholder="My gameplay check"
|
placeholder="My gameplay check"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -808,19 +809,15 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.add-row label {
|
|
||||||
|
.builder .add-row > :global(.field) {
|
||||||
flex: 1 1 220px;
|
flex: 1 1 220px;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
.builder :global(.field) {
|
||||||
gap: 7px;
|
|
||||||
flex: 1 1 180px;
|
flex: 1 1 180px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select,
|
|
||||||
button {
|
button {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
@@ -830,14 +827,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
min-height: 40px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-color: var(--border-control);
|
|
||||||
}
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -846,9 +836,7 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
summary:focus-visible,
|
summary:focus-visible {
|
||||||
input:focus-visible,
|
|
||||||
select:focus-visible {
|
|
||||||
outline: 2px solid var(--accent-primary-hover);
|
outline: 2px solid var(--accent-primary-hover);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
@@ -413,27 +416,27 @@
|
|||||||
current progress uses only the current attempt.
|
current progress uses only the current attempt.
|
||||||
</p>
|
</p>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label
|
<Field label="Server">
|
||||||
>Server<select bind:value={evidenceServer}
|
<Select bind:value={evidenceServer}
|
||||||
><option value="">All servers</option
|
><option value="">All servers</option
|
||||||
>{#each [...new Set(test.evidence.map((event) => event.server))] as server}<option
|
>{#each [...new Set(test.evidence.map((event) => event.server))] as server}<option
|
||||||
value={server}>{serverTitle(server)}</option
|
value={server}>{serverTitle(server)}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
><label
|
><Field label="Event type">
|
||||||
>Event type<select bind:value={evidenceKind}
|
<Select bind:value={evidenceKind}
|
||||||
><option value="">All events</option><option value="gameplay"
|
><option value="">All events</option><option value="gameplay"
|
||||||
>Gameplay reports</option
|
>Gameplay reports</option
|
||||||
><option value="health">Health observations</option><option
|
><option value="health">Health observations</option><option
|
||||||
value="manual-sign-off">Tester sign-offs</option
|
value="manual-sign-off">Tester sign-offs</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
><label
|
><Field label="Search evidence">
|
||||||
>Search evidence<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={evidenceQuery}
|
bind:value={evidenceQuery}
|
||||||
placeholder="Round, map, tester, observation…"
|
placeholder="Round, map, tester, observation…"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#each filteredEvidence.slice(0, evidenceLimit) as event (event.id)}<article
|
{#each filteredEvidence.slice(0, evidenceLimit) as event (event.id)}<article
|
||||||
@@ -683,14 +686,15 @@
|
|||||||
.signoff label {
|
.signoff label {
|
||||||
flex: 1 1 240px;
|
flex: 1 1 240px;
|
||||||
}
|
}
|
||||||
|
.signoff :global(.field) {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
label {
|
label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
textarea {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -714,6 +718,10 @@
|
|||||||
flex: 1 1 180px;
|
flex: 1 1 180px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.filters :global(.field) {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.evidence {
|
.evidence {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
import type { BlueprintSummary } from '$lib/deployment-plan.js';
|
import type { BlueprintSummary } from '$lib/deployment-plan.js';
|
||||||
@@ -184,21 +187,21 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="identity">
|
<div class="identity">
|
||||||
<label
|
<Field label="Collection name">
|
||||||
>Collection name<input
|
<Input
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
maxlength="80"
|
maxlength={80}
|
||||||
placeholder="Friday Zombies"
|
placeholder="Friday Zombies"
|
||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/></label
|
/></Field
|
||||||
><label
|
><Field label="Description (optional)">
|
||||||
>Description (optional)<input
|
<Input
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
maxlength="500"
|
maxlength={500}
|
||||||
placeholder="Our weekend lineup"
|
placeholder="Our weekend lineup"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="lineup">
|
<div class="lineup">
|
||||||
@@ -211,8 +214,8 @@
|
|||||||
{#each rows as row, index (row.key)}
|
{#each rows as row, index (row.key)}
|
||||||
<div class="map-row">
|
<div class="map-row">
|
||||||
<span class="number">{String(index + 1).padStart(2, '0')}</span>
|
<span class="number">{String(index + 1).padStart(2, '0')}</span>
|
||||||
<label
|
<Field label="Game">
|
||||||
>Game<select
|
<Select
|
||||||
aria-label={`Game ${index + 1}`}
|
aria-label={`Game ${index + 1}`}
|
||||||
bind:value={row.game}
|
bind:value={row.game}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -222,11 +225,11 @@
|
|||||||
?.slug ?? '')}
|
?.slug ?? '')}
|
||||||
><option value="t5">Black Ops I</option><option value="t6"
|
><option value="t5">Black Ops I</option><option value="t6"
|
||||||
>Black Ops II</option
|
>Black Ops II</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Map">
|
||||||
>Map<select
|
<Select
|
||||||
aria-label={`Map ${index + 1}`}
|
aria-label={`Map ${index + 1}`}
|
||||||
aria-invalid={duplicate(row) || undefined}
|
aria-invalid={duplicate(row) || undefined}
|
||||||
aria-describedby={duplicate(row)
|
aria-describedby={duplicate(row)
|
||||||
@@ -242,13 +245,13 @@
|
|||||||
other.game === row.game &&
|
other.game === row.game &&
|
||||||
other.mapSlug === map.slug
|
other.mapSlug === map.slug
|
||||||
)}>{map.friendly}</option
|
)}>{map.friendly}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}
|
>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}
|
||||||
>Already selected. Choose another map.</span
|
>Already selected. Choose another map.</span
|
||||||
>{/if}</label
|
>{/if}</Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Target lobbies">
|
||||||
>Target lobbies<input
|
<Input
|
||||||
aria-label={`Lobbies ${index + 1}`}
|
aria-label={`Lobbies ${index + 1}`}
|
||||||
aria-invalid={!(
|
aria-invalid={!(
|
||||||
Number.isInteger(row.count) &&
|
Number.isInteger(row.count) &&
|
||||||
@@ -263,7 +266,7 @@
|
|||||||
bind:value={row.count}
|
bind:value={row.count}
|
||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/><span id={`count-hint-${row.key}`}>1–15</span></label
|
/><span id={`count-hint-${row.key}`}>1–15</span></Field
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -371,24 +374,7 @@
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 7px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 11px;
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.lineup {
|
.lineup {
|
||||||
border-block: 1px solid var(--border-subtle);
|
border-block: 1px solid var(--border-subtle);
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
@@ -457,8 +443,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.map-row label:first-of-type,
|
|
||||||
.map-row label:nth-of-type(2) {
|
.map-row > :global(.field:first-of-type) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.map-row > :global(.field:nth-of-type(2)) {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import type { CapacitySamplePage } from '$lib/hosts.js';
|
import type { CapacitySamplePage } from '$lib/hosts.js';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
@@ -59,18 +61,18 @@
|
|||||||
measurements from other hosts used by fleet allowances.
|
measurements from other hosts used by fleet allowances.
|
||||||
</p>
|
</p>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<label
|
<Field label="Source">
|
||||||
>Source<select bind:value={scope} onchange={() => (page = 1)}
|
<Select bind:value={scope} onchange={() => (page = 1)}
|
||||||
><option value="host">This host</option><option value="fleet"
|
><option value="host">This host</option><option value="fleet"
|
||||||
>Entire fleet</option
|
>Entire fleet</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Phase">
|
||||||
>Phase<select bind:value={phase} onchange={() => (page = 1)}
|
<Select bind:value={phase} onchange={() => (page = 1)}
|
||||||
><option value="all">All phases</option><option value="startup">Starting</option
|
><option value="all">All phases</option><option value="startup">Starting</option
|
||||||
><option value="running">Running</option></select
|
><option value="running">Running</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -182,18 +184,7 @@
|
|||||||
nav {
|
nav {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
padding: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { UserRole } from '$lib/auth.js';
|
import type { UserRole } from '$lib/auth.js';
|
||||||
@@ -154,23 +156,24 @@
|
|||||||
description="Jump to a page, server, host, player, or match."
|
description="Jump to a page, server, host, player, or match."
|
||||||
>
|
>
|
||||||
<div class="palette">
|
<div class="palette">
|
||||||
<label class="sr-only" for={`${id}-search`}>Search Electric Cherry</label>
|
<Field label="Search Electric Cherry" forId={`${id}-search`} hiddenLabel>
|
||||||
<input
|
<Input
|
||||||
bind:this={input}
|
bind:element={input}
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
id={`${id}-search`}
|
id={`${id}-search`}
|
||||||
type="search"
|
type="search"
|
||||||
maxlength="100"
|
maxlength={100}
|
||||||
placeholder="Search Electric Cherry…"
|
placeholder="Search Electric Cherry…"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={`${id}-results`}
|
aria-controls={`${id}-results`}
|
||||||
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined}
|
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined}
|
||||||
aria-describedby={`${id}-hint`}
|
aria-describedby={`${id}-hint`}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
onkeydown={keydown}
|
onkeydown={keydown}
|
||||||
/>
|
/></Field
|
||||||
|
>
|
||||||
<p class="hint" id={`${id}-hint`}>
|
<p class="hint" id={`${id}-hint`}>
|
||||||
Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span>
|
Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -228,17 +231,7 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: var(--space-3);
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.hint,
|
.hint,
|
||||||
.status,
|
.status,
|
||||||
.error {
|
.error {
|
||||||
@@ -296,15 +289,4 @@
|
|||||||
.error {
|
.error {
|
||||||
color: var(--state-warning);
|
color: var(--state-warning);
|
||||||
}
|
}
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
@@ -230,8 +233,8 @@
|
|||||||
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
|
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="version-toolbar">
|
<div class="version-toolbar">
|
||||||
<label
|
<Field label="Starting version">
|
||||||
>Starting version<select
|
<Select
|
||||||
aria-label="Starting version"
|
aria-label="Starting version"
|
||||||
value={selected}
|
value={selected}
|
||||||
onchange={changeVersion}
|
onchange={changeVersion}
|
||||||
@@ -240,8 +243,8 @@
|
|||||||
>v{version.version}{version.id === currentVersionId
|
>v{version.version}{version.id === currentVersionId
|
||||||
? ' · promoted'
|
? ' · promoted'
|
||||||
: ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option
|
: ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label class="file-button"
|
<label class="file-button"
|
||||||
>Import .cfg<input
|
>Import .cfg<input
|
||||||
@@ -274,12 +277,12 @@
|
|||||||
overlay={kind === 'config'}
|
overlay={kind === 'config'}
|
||||||
disabled={saving || runningBetaId !== null}
|
disabled={saving || runningBetaId !== null}
|
||||||
/>
|
/>
|
||||||
<label class="notes-label"
|
<Field class="notes-label" label="Version notes">
|
||||||
>Version notes<input
|
<Input
|
||||||
bind:value={notes}
|
bind:value={notes}
|
||||||
placeholder="What changed, and why?"
|
placeholder="What changed, and why?"
|
||||||
disabled={saving || runningBetaId !== null}
|
disabled={saving || runningBetaId !== null}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<div class="actions ui-sticky-actions">
|
<div class="actions ui-sticky-actions">
|
||||||
<span class="muted"
|
<span class="muted"
|
||||||
@@ -355,13 +358,15 @@
|
|||||||
.version-toolbar > label:first-child {
|
.version-toolbar > label:first-child {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
.version-toolbar > :global(.field:first-child) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
label {
|
label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
select,
|
|
||||||
input:not([type='file']) {
|
input:not([type='file']) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -373,7 +378,6 @@
|
|||||||
font: var(--text-sm) var(--font-body);
|
font: var(--text-sm) var(--font-body);
|
||||||
}
|
}
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
select:focus-visible,
|
|
||||||
.file-button:focus-within {
|
.file-button:focus-within {
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -394,9 +398,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.notes-label {
|
.editor-panel :global(.notes-label) {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
@@ -441,8 +446,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']):not([type='file']),
|
input:not([type='checkbox']):not([type='file']) {
|
||||||
select {
|
|
||||||
border-color: var(--border-control);
|
border-color: var(--border-control);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
||||||
@@ -191,8 +194,8 @@
|
|||||||
? 'Your draft composed with the other promoted configs. Saving creates an untested version.'
|
? 'Your draft composed with the other promoted configs. Saving creates an untested version.'
|
||||||
: 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}
|
: 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}
|
||||||
</p>
|
</p>
|
||||||
{#if !fixedServer}<label class="server-label"
|
{#if !fixedServer}<Field class="server-label" label="Server">
|
||||||
>Server<select
|
<Select
|
||||||
aria-label="Server"
|
aria-label="Server"
|
||||||
bind:value={selected}
|
bind:value={selected}
|
||||||
disabled={!!fixedServer || !eligible.length}
|
disabled={!!fixedServer || !eligible.length}
|
||||||
@@ -201,7 +204,7 @@
|
|||||||
>{serverTitle(server.name)}</option
|
>{serverTitle(server.name)}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
||||||
</select></label
|
</Select></Field
|
||||||
>{/if}
|
>{/if}
|
||||||
{#if error}<div class="error" role="alert">
|
{#if error}<div class="error" role="alert">
|
||||||
{error}{inspection ? ' Showing the last completed preview.' : ''}
|
{error}{inspection ? ' Showing the last completed preview.' : ''}
|
||||||
@@ -274,11 +277,13 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input
|
<Field label="Search effective settings" hiddenLabel class="search"
|
||||||
aria-label="Search effective settings"
|
><Input
|
||||||
bind:value={query}
|
aria-label="Search effective settings"
|
||||||
placeholder="Search settings or sources…"
|
bind:value={query}
|
||||||
/><label class="check"
|
placeholder="Search settings or sources…"
|
||||||
|
/></Field
|
||||||
|
><label class="check"
|
||||||
><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label
|
><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,13 +384,12 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
margin: 12px 0 20px;
|
margin: 12px 0 20px;
|
||||||
}
|
}
|
||||||
.server-label {
|
.inspector :global(.server-label) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
select,
|
|
||||||
input:not([type='checkbox']) {
|
input:not([type='checkbox']) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -396,7 +400,6 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font: var(--text-sm) var(--font-body);
|
font: var(--text-sm) var(--font-body);
|
||||||
}
|
}
|
||||||
select:focus-visible,
|
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
summary:focus-visible,
|
summary:focus-visible,
|
||||||
@@ -480,12 +483,13 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
.filters > :global(.search) {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
.filters {
|
.filters {
|
||||||
margin: 16px 0 8px;
|
margin: 16px 0 8px;
|
||||||
}
|
}
|
||||||
.filters > input {
|
|
||||||
flex: 1 1 180px;
|
|
||||||
}
|
|
||||||
.check {
|
.check {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -532,6 +536,7 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-label::before {
|
.source-label::before {
|
||||||
content: '▸';
|
content: '▸';
|
||||||
}
|
}
|
||||||
@@ -610,7 +615,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']):not([type='file']),
|
input:not([type='checkbox']):not([type='file']),
|
||||||
select,
|
|
||||||
textarea {
|
textarea {
|
||||||
border-color: var(--border-control);
|
border-color: var(--border-control);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -247,28 +250,35 @@
|
|||||||
<ConfigInspector />
|
<ConfigInspector />
|
||||||
{:else}
|
{:else}
|
||||||
{#if loading || artifacts.length || error}<div class="library-toolbar">
|
{#if loading || artifacts.length || error}<div class="library-toolbar">
|
||||||
<input
|
<Field label="Search configs" hiddenLabel class="search"
|
||||||
type="search"
|
><Input
|
||||||
class="search"
|
type="search"
|
||||||
aria-label="Search configs"
|
aria-label="Search configs"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder="Search configs, maps, or servers…"
|
placeholder="Search configs, maps, or servers…"
|
||||||
/>
|
/></Field
|
||||||
<select aria-label="Filter by game" bind:value={game}
|
|
||||||
><option value="">All games</option>{#each gameOptions as option}<option
|
|
||||||
value={option}>{gameTitle(option)}</option
|
|
||||||
>{/each}</select
|
|
||||||
>
|
>
|
||||||
<select aria-label="Filter by scope" bind:value={scope}
|
<Field label="Filter by game" hiddenLabel
|
||||||
><option value="">All scopes</option><option value="game">Game</option
|
><Select aria-label="Filter by game" bind:value={game}
|
||||||
><option value="map">Map</option></select
|
><option value="">All games</option>{#each gameOptions as option}<option
|
||||||
|
value={option}>{gameTitle(option)}</option
|
||||||
|
>{/each}</Select
|
||||||
|
></Field
|
||||||
>
|
>
|
||||||
<select aria-label="Filter by status" bind:value={status}
|
<Field label="Filter by scope" hiddenLabel
|
||||||
><option value="">All statuses</option><option value="pending"
|
><Select aria-label="Filter by scope" bind:value={scope}
|
||||||
>Awaiting promotion</option
|
><option value="">All scopes</option><option value="game">Game</option
|
||||||
><option value="promoted">Has promoted version</option><option value="beta"
|
><option value="map">Map</option></Select
|
||||||
>Active beta</option
|
></Field
|
||||||
><option value="failed">Test failed</option></select
|
>
|
||||||
|
<Field label="Filter by status" hiddenLabel
|
||||||
|
><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
|
||||||
|
></Field
|
||||||
>
|
>
|
||||||
{#if filtered.length && (query || game || scope || status)}<Button
|
{#if filtered.length && (query || game || scope || status)}<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -390,15 +400,15 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label
|
<Field label="Name">
|
||||||
>Name<input
|
<Input
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'}
|
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'}
|
||||||
required
|
required
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/></label
|
/></Field
|
||||||
><label
|
><Field label="Game">
|
||||||
>Game<select
|
<Select
|
||||||
aria-label="Game"
|
aria-label="Game"
|
||||||
bind:value={scopeGame}
|
bind:value={scopeGame}
|
||||||
onchange={() => (scopeMap = '')}
|
onchange={() => (scopeMap = '')}
|
||||||
@@ -406,16 +416,16 @@
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
><option value="" disabled>Choose a game</option
|
><option value="" disabled>Choose a game</option
|
||||||
>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option
|
>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{#if kind === 'config'}<label
|
{#if kind === 'config'}<Field label="Apply to">
|
||||||
>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}
|
<Select aria-label="Apply to" bind:value={scopeType} disabled={saving}
|
||||||
><option value="game">Whole game</option><option value="map">One map</option
|
><option value="game">Whole game</option><option value="map">One map</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>{#if scopeType === 'map'}<label
|
>{#if scopeType === 'map'}<Field label="Map">
|
||||||
>Map<select
|
<Select
|
||||||
aria-label="Map"
|
aria-label="Map"
|
||||||
bind:value={scopeMap}
|
bind:value={scopeMap}
|
||||||
required
|
required
|
||||||
@@ -423,8 +433,8 @@
|
|||||||
><option value="" disabled>Choose a map</option
|
><option value="" disabled>Choose a map</option
|
||||||
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option
|
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option
|
||||||
value={option}>{option}</option
|
value={option}>{option}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>{/if}{/if}
|
>{/if}{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if baselineExists}<p class="warning">
|
{#if baselineExists}<p class="warning">
|
||||||
@@ -442,12 +452,12 @@
|
|||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
|
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
|
||||||
<label class="notes"
|
<Field class="notes" label="Notes">
|
||||||
>Notes<input
|
<Input
|
||||||
bind:value={notes}
|
bind:value={notes}
|
||||||
placeholder="What is this config for?"
|
placeholder="What is this config for?"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
|
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
@@ -504,13 +514,11 @@
|
|||||||
}
|
}
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
select:focus-visible,
|
|
||||||
a:focus-visible {
|
a:focus-visible {
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
input,
|
input {
|
||||||
select {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
@@ -653,8 +661,7 @@
|
|||||||
.loading {
|
.loading {
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']):not([type='file']),
|
input:not([type='checkbox']):not([type='file']) {
|
||||||
select {
|
|
||||||
border-color: var(--border-control);
|
border-color: var(--border-control);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import {
|
import {
|
||||||
addConfigSetting,
|
addConfigSetting,
|
||||||
changeConfigValue,
|
changeConfigValue,
|
||||||
@@ -69,44 +71,50 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
<input
|
<Field label="Find a setting" hiddenLabel class="search"
|
||||||
class="search"
|
><Input
|
||||||
aria-label="Find a setting"
|
aria-label="Find a setting"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
placeholder="Find a setting…"
|
placeholder="Find a setting…"
|
||||||
/>
|
/></Field
|
||||||
|
>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Edit values and inline comments. Other commands and formatting stay in the raw file.
|
Edit values and inline comments. Other commands and formatting stay in the raw file.
|
||||||
</p>
|
</p>
|
||||||
{#each visible as entry (entry.index)}
|
{#each visible as entry (entry.index)}
|
||||||
<label class="setting"
|
<Field class="setting"
|
||||||
><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
|
>{#snippet label()}<span
|
||||||
<input
|
><code>{entry.key}</code><small>Line {entry.index + 1}</small></span
|
||||||
|
>{/snippet}
|
||||||
|
<Input
|
||||||
|
mono
|
||||||
value={entry.value}
|
value={entry.value}
|
||||||
{disabled}
|
{disabled}
|
||||||
aria-label={`${entry.key}, line ${entry.index + 1}`}
|
aria-label={`${entry.key}, line ${entry.index + 1}`}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
(text = changeConfigValue(text, entry.index, e.currentTarget.value))}
|
(text = changeConfigValue(text, entry.index, e.currentTarget.value))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</Field>
|
||||||
{:else}<p class="hint">
|
{:else}<p class="hint">
|
||||||
{entries.length ? 'No matching settings.' : 'Add your first setting below.'}
|
{entries.length ? 'No matching settings.' : 'Add your first setting below.'}
|
||||||
</p>{/each}
|
</p>{/each}
|
||||||
<div class="add-setting">
|
<div class="add-setting">
|
||||||
<label
|
<Field label="Setting name">
|
||||||
>Setting name<input
|
<Input
|
||||||
|
mono
|
||||||
bind:value={newKey}
|
bind:value={newKey}
|
||||||
onkeydown={addOnEnter}
|
onkeydown={addOnEnter}
|
||||||
placeholder="g_inactivity"
|
placeholder="g_inactivity"
|
||||||
{disabled}
|
{disabled}
|
||||||
/></label
|
/></Field
|
||||||
><label
|
><Field label="Value">
|
||||||
>Value<input
|
<Input
|
||||||
|
mono
|
||||||
bind:value={newValue}
|
bind:value={newValue}
|
||||||
onkeydown={addOnEnter}
|
onkeydown={addOnEnter}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
{disabled}
|
{disabled}
|
||||||
/></label
|
/></Field
|
||||||
><Button
|
><Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={addSetting}
|
onclick={addSetting}
|
||||||
@@ -170,7 +178,6 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
|
||||||
textarea:focus-visible {
|
textarea:focus-visible {
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
@@ -189,8 +196,7 @@
|
|||||||
font: var(--text-sm)/1.8 var(--font-mono);
|
font: var(--text-sm)/1.8 var(--font-mono);
|
||||||
tab-size: 4;
|
tab-size: 4;
|
||||||
}
|
}
|
||||||
textarea:disabled,
|
textarea:disabled {
|
||||||
input:disabled {
|
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
.settings {
|
.settings {
|
||||||
@@ -198,17 +204,8 @@
|
|||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
input {
|
|
||||||
box-sizing: border-box;
|
.settings :global(.setting) {
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 9px 12px;
|
|
||||||
font: var(--text-sm) var(--font-mono);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.setting {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr);
|
grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -216,7 +213,7 @@
|
|||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
.setting span {
|
.settings :global(.setting span) {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@@ -228,11 +225,9 @@
|
|||||||
border-top: 1px solid var(--border-strong);
|
border-top: 1px solid var(--border-strong);
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
.add-setting label {
|
|
||||||
|
.add-setting :global(.field) {
|
||||||
flex: 1 1 140px;
|
flex: 1 1 140px;
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
}
|
||||||
code {
|
code {
|
||||||
font: var(--text-sm) var(--font-mono);
|
font: var(--text-sm) var(--font-mono);
|
||||||
@@ -259,7 +254,7 @@
|
|||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.setting {
|
.settings :global(.setting) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -267,7 +262,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']):not([type='file']),
|
|
||||||
textarea {
|
textarea {
|
||||||
border-color: var(--border-control);
|
border-color: var(--border-control);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import Dialog from './primitives/Dialog.svelte';
|
import Dialog from './primitives/Dialog.svelte';
|
||||||
@@ -246,9 +248,9 @@
|
|||||||
<p class="muted" role="status">Loading maps…</p>
|
<p class="muted" role="status">Loading maps…</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label
|
<Field label="Game">
|
||||||
>Game<select
|
<Select
|
||||||
bind:this={gameSelect}
|
bind:element={gameSelect}
|
||||||
bind:value={game}
|
bind:value={game}
|
||||||
disabled={launching || unconfirmedLaunch}
|
disabled={launching || unconfirmedLaunch}
|
||||||
onchange={(event) => {
|
onchange={(event) => {
|
||||||
@@ -260,16 +262,16 @@
|
|||||||
<option value="t5">Black Ops I</option><option value="t6"
|
<option value="t5">Black Ops I</option><option value="t6"
|
||||||
>Black Ops II</option
|
>Black Ops II</option
|
||||||
>
|
>
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Map">
|
||||||
>Map<select
|
<Select
|
||||||
bind:value={mapSlug}
|
bind:value={mapSlug}
|
||||||
disabled={launching || unconfirmedLaunch}
|
disabled={launching || unconfirmedLaunch}
|
||||||
onchange={selectionChanged}
|
onchange={selectionChanged}
|
||||||
>
|
>
|
||||||
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
|
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -414,15 +416,7 @@
|
|||||||
grid-template-columns: 1fr 1.3fr;
|
grid-template-columns: 1fr 1.3fr;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.summary {
|
.summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
|
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
|
||||||
import { actionLabels } from '$lib/provisioning.js';
|
import { actionLabels } from '$lib/provisioning.js';
|
||||||
@@ -130,10 +132,10 @@
|
|||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label class="action"
|
<Field class="action" label="Action">
|
||||||
>Action<select bind:value={action} disabled={busy} onchange={chooseAction}>
|
<Select bind:value={action} disabled={busy} onchange={chooseAction}>
|
||||||
{#each fleetActions as value}<option {value}>{actionLabels[value]}</option>{/each}
|
{#each fleetActions as value}<option {value}>{actionLabels[value]}</option>{/each}
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
<p>{descriptions[action]}</p>
|
<p>{descriptions[action]}</p>
|
||||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
@@ -236,23 +238,14 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.fields,
|
.fields,
|
||||||
.action {
|
.fields :global(.action) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
.action {
|
.fields :global(.action) {
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
p,
|
p,
|
||||||
ul {
|
ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
|
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
|
||||||
@@ -145,20 +147,16 @@
|
|||||||
<Dialog bind:open {title} {busy}>
|
<Dialog bind:open {title} {busy}>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
{#if !initialServer}
|
{#if !initialServer}
|
||||||
<label
|
<Field label="Server">
|
||||||
>Server <select
|
<Select bind:value={server} disabled={busy || loading} onchange={chooseServer}>
|
||||||
bind:value={server}
|
|
||||||
disabled={busy || loading}
|
|
||||||
onchange={chooseServer}
|
|
||||||
>
|
|
||||||
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
|
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
|
||||||
{#each servers as item (item.name)}<option value={item.name}
|
{#each servers as item (item.name)}<option value={item.name}
|
||||||
>{serverTitle(item.name)}</option
|
>{serverTitle(item.name)}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Action">
|
||||||
>Action <select
|
<Select
|
||||||
bind:value={action}
|
bind:value={action}
|
||||||
disabled={busy || !server || loading}
|
disabled={busy || !server || loading}
|
||||||
onchange={() => {
|
onchange={() => {
|
||||||
@@ -172,7 +170,7 @@
|
|||||||
>{/if}
|
>{/if}
|
||||||
{#each available as value}<option {value}>{actionLabels[value]}</option>{/each}
|
{#each available as value}<option {value}>{actionLabels[value]}</option>{/each}
|
||||||
{#if server}<option value="destroy">Destroy server</option>{/if}
|
{#if server}<option value="destroy">Destroy server</option>{/if}
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if loading}<p role="status">Checking the latest server status…</p>
|
{#if loading}<p role="status">Checking the latest server status…</p>
|
||||||
@@ -219,13 +217,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
select {
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { tick, untrack, onMount, onDestroy } from 'svelte';
|
import { tick, untrack, onMount, onDestroy } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -193,12 +195,8 @@
|
|||||||
retryDisabled={loading}
|
retryDisabled={loading}
|
||||||
/>
|
/>
|
||||||
<div class="log-tools">
|
<div class="log-tools">
|
||||||
<label class="log-search"
|
<Field class="log-search" label="Search loaded lines">
|
||||||
>Search loaded lines<input
|
<Input type="search" bind:value={query} placeholder="Find text in this log" /></Field
|
||||||
type="search"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder="Find text in this log"
|
|
||||||
/></label
|
|
||||||
>
|
>
|
||||||
<details class="log-actions">
|
<details class="log-actions">
|
||||||
<summary>More</summary>
|
<summary>More</summary>
|
||||||
@@ -302,7 +300,7 @@
|
|||||||
.log-tools {
|
.log-tools {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.log-search {
|
.log-tools :global(.log-search) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -310,14 +308,6 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.log-search input {
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 9px 12px;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.copy-status {
|
.copy-status {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
@@ -129,17 +132,17 @@
|
|||||||
<section class="history" aria-label="Match history">
|
<section class="history" aria-label="Match history">
|
||||||
{#snippet filterFields(suffix: string)}
|
{#snippet filterFields(suffix: string)}
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
{#if server === undefined}<label for={`match-game${suffix}`}
|
{#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
|
||||||
>Game<select
|
<Select
|
||||||
id={`match-game${suffix}`}
|
id={`match-game${suffix}`}
|
||||||
value={filters.game}
|
value={filters.game}
|
||||||
onchange={(event) => update({ game: event.currentTarget.value, map: '' })}
|
onchange={(event) => update({ game: event.currentTarget.value, map: '' })}
|
||||||
><option value="">All games</option><option value="t5">Black Ops I</option
|
><option value="">All games</option><option value="t5">Black Ops I</option
|
||||||
><option value="t6">Black Ops II</option></select
|
><option value="t6">Black Ops II</option></Select
|
||||||
></label
|
></Field
|
||||||
>{/if}
|
>{/if}
|
||||||
<label for={`match-map${suffix}`}
|
<Field forId={`match-map${suffix}`} label="Map">
|
||||||
>Map<select
|
<Select
|
||||||
id={`match-map${suffix}`}
|
id={`match-map${suffix}`}
|
||||||
value={filters.map}
|
value={filters.map}
|
||||||
onchange={(event) => update({ map: event.currentTarget.value })}
|
onchange={(event) => update({ map: event.currentTarget.value })}
|
||||||
@@ -153,7 +156,7 @@
|
|||||||
? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}`
|
? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}`
|
||||||
: ''}</option
|
: ''}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
{#if server === undefined}<form
|
{#if server === undefined}<form
|
||||||
role="search"
|
role="search"
|
||||||
@@ -163,32 +166,32 @@
|
|||||||
if (suffix) filtersOpen = false;
|
if (suffix) filtersOpen = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label for={`match-search${suffix}`}
|
<Field forId={`match-search${suffix}`} label="Server">
|
||||||
>Server<input
|
<Input
|
||||||
id={`match-search${suffix}`}
|
id={`match-search${suffix}`}
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
maxlength="100"
|
maxlength={100}
|
||||||
placeholder="Find a server…"
|
placeholder="Find a server…"
|
||||||
aria-describedby={`match-search-help${suffix}`}
|
aria-describedby={`match-search-help${suffix}`}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<span id={`match-search-help${suffix}`} class="sr-only"
|
<span id={`match-search-help${suffix}`} class="sr-only"
|
||||||
>Press Enter to search by server name.</span
|
>Press Enter to search by server name.</span
|
||||||
>
|
>
|
||||||
</form>{/if}
|
</form>{/if}
|
||||||
<label for={`match-days${suffix}`}
|
<Field forId={`match-days${suffix}`} label="Date range">
|
||||||
>Date range<select
|
<Select
|
||||||
id={`match-days${suffix}`}
|
id={`match-days${suffix}`}
|
||||||
value={String(filters.days)}
|
value={String(filters.days)}
|
||||||
onchange={(event) => update({ days: event.currentTarget.value })}
|
onchange={(event) => update({ days: event.currentTarget.value })}
|
||||||
><option value="0">All time</option><option value="7">Last 7 days</option
|
><option value="0">All time</option><option value="7">Last 7 days</option
|
||||||
><option value="30">Last 30 days</option><option value="90">Last 90 days</option
|
><option value="30">Last 30 days</option><option value="90">Last 90 days</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label for={`match-status${suffix}`}
|
<Field forId={`match-status${suffix}`} label="Status">
|
||||||
>Status<select
|
<Select
|
||||||
id={`match-status${suffix}`}
|
id={`match-status${suffix}`}
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onchange={(event) => update({ status: event.currentTarget.value })}
|
onchange={(event) => update({ status: event.currentTarget.value })}
|
||||||
@@ -196,28 +199,28 @@
|
|||||||
>Completed</option
|
>Completed</option
|
||||||
><option value="active">In progress</option><option value="interrupted"
|
><option value="active">In progress</option><option value="interrupted"
|
||||||
>Interrupted</option
|
>Interrupted</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{#if suffix}
|
{#if suffix}
|
||||||
<label for={`match-sort${suffix}`}
|
<Field forId={`match-sort${suffix}`} label="Sort by">
|
||||||
>Sort by<select
|
<Select
|
||||||
id={`match-sort${suffix}`}
|
id={`match-sort${suffix}`}
|
||||||
value={filters.sort}
|
value={filters.sort}
|
||||||
onchange={(event) => update({ sort: event.currentTarget.value })}
|
onchange={(event) => update({ sort: event.currentTarget.value })}
|
||||||
>{#each sorts as item}<option value={item.key}>{item.label}</option
|
>{#each sorts as item}<option value={item.key}>{item.label}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label for={`match-order${suffix}`}
|
<Field forId={`match-order${suffix}`} label="Order">
|
||||||
>Order<select
|
<Select
|
||||||
id={`match-order${suffix}`}
|
id={`match-order${suffix}`}
|
||||||
value={filters.direction}
|
value={filters.direction}
|
||||||
onchange={(event) => update({ direction: event.currentTarget.value })}
|
onchange={(event) => update({ direction: event.currentTarget.value })}
|
||||||
><option value="desc">Highest or newest first</option><option value="asc"
|
><option value="desc">Highest or newest first</option><option value="asc"
|
||||||
>Lowest or oldest first</option
|
>Lowest or oldest first</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -460,30 +463,19 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
label,
|
|
||||||
form {
|
form {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 145px;
|
min-width: 145px;
|
||||||
}
|
}
|
||||||
label {
|
.filters :global(.field) {
|
||||||
display: flex;
|
flex: 1;
|
||||||
|
min-width: 145px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters :global(.field) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -640,10 +632,12 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
.filters > label,
|
|
||||||
.filters > form {
|
.filters > form {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.filters > :global(.field) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.context {
|
.context {
|
||||||
gap: var(--space-1) var(--space-2);
|
gap: var(--space-1) var(--space-2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { gameTitle } from '$ui/server-labels.js';
|
import { gameTitle } from '$ui/server-labels.js';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
|
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
|
||||||
@@ -148,36 +150,36 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet runtimeFields()}
|
{#snippet runtimeFields()}
|
||||||
<label
|
<Field label={compact ? 'Runtime image' : 'Plutainer runtime image'}>
|
||||||
>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input
|
<Input
|
||||||
required
|
required
|
||||||
bind:value={selection.image}
|
bind:value={selection.image}
|
||||||
{disabled}
|
{disabled}
|
||||||
oninput={() => onchange?.()}
|
oninput={() => onchange?.()}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
|
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet networkFields()}
|
{#snippet networkFields()}
|
||||||
<label
|
<Field label="Local bind address">
|
||||||
>Local bind address<input
|
<Input
|
||||||
bind:value={selection.bindAddress}
|
bind:value={selection.bindAddress}
|
||||||
placeholder="Host default"
|
placeholder="Host default"
|
||||||
{disabled}
|
{disabled}
|
||||||
oninput={() => onchange?.()}
|
oninput={() => onchange?.()}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Public / NAT address">
|
||||||
>Public / NAT address<input
|
<Input
|
||||||
bind:value={selection.publicAddress}
|
bind:value={selection.publicAddress}
|
||||||
placeholder="Host default"
|
placeholder="Host default"
|
||||||
{disabled}
|
{disabled}
|
||||||
oninput={() => onchange?.()}
|
oninput={() => onchange?.()}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Public / NAT port">
|
||||||
>Public / NAT port<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
@@ -185,7 +187,7 @@
|
|||||||
placeholder="Same as local port"
|
placeholder="Same as local port"
|
||||||
{disabled}
|
{disabled}
|
||||||
oninput={() => onchange?.()}
|
oninput={() => onchange?.()}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<p>Public mapping does not change the local Docker port reservation.</p>
|
<p>Public mapping does not change the local Docker port reservation.</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -317,21 +319,7 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 7px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 11px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
p {
|
p {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -360,7 +348,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
details label {
|
|
||||||
|
details :global(.field) {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
details p {
|
details p {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
|
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
@@ -54,19 +57,19 @@
|
|||||||
>
|
>
|
||||||
{#if events.length}
|
{#if events.length}
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
<label
|
<Field label="Search activity">
|
||||||
>Search activity<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
oninput={() => (limit = 25)}
|
oninput={() => (limit = 25)}
|
||||||
placeholder="Server, stage, or message"
|
placeholder="Server, stage, or message"
|
||||||
/></label
|
/></Field
|
||||||
><label
|
><Field label="Order">
|
||||||
>Order<select bind:value={newestFirst}
|
<Select bind:value={newestFirst}
|
||||||
><option value={true}>Newest first</option><option value={false}
|
><option value={true}>Newest first</option><option value={false}
|
||||||
>Oldest first</option
|
>Oldest first</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<ol class="timeline">
|
<ol class="timeline">
|
||||||
@@ -133,25 +136,12 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
.tools > :global(.field:first-child) {
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
label:first-child {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select {
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.time,
|
.time,
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -184,9 +187,8 @@
|
|||||||
<p>Import a release below to track updates for this plugin.</p>
|
<p>Import a release below to track updates for this plugin.</p>
|
||||||
{/if}
|
{/if}
|
||||||
<form onsubmit={preview} class="lookup">
|
<form onsubmit={preview} class="lookup">
|
||||||
<label
|
<Field label="Repository or release URL">
|
||||||
>Repository or release URL
|
<Input
|
||||||
<input
|
|
||||||
type="url"
|
type="url"
|
||||||
bind:value={url}
|
bind:value={url}
|
||||||
oninput={() => {
|
oninput={() => {
|
||||||
@@ -197,7 +199,7 @@
|
|||||||
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0"
|
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0"
|
||||||
disabled={busy || disabled}
|
disabled={busy || disabled}
|
||||||
/>
|
/>
|
||||||
</label>
|
</Field>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={busy && !release}
|
loading={busy && !release}
|
||||||
@@ -228,29 +230,29 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label
|
<Field label="DLL asset">
|
||||||
>DLL asset<select bind:value={assetId} disabled={busy || disabled}>
|
<Select bind:value={assetId} disabled={busy || disabled}>
|
||||||
<option value="" disabled>Choose a DLL…</option>
|
<option value="" disabled>Choose a DLL…</option>
|
||||||
{#each release.assets as asset}<option value={String(asset.id)}
|
{#each release.assets as asset}<option value={String(asset.id)}
|
||||||
>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option
|
>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
{#if !pluginId}
|
{#if !pluginId}
|
||||||
<label
|
<Field label="Plugin name">
|
||||||
>Plugin name<input
|
<Input
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
placeholder="t6-gsc-utils"
|
placeholder="t6-gsc-utils"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Deploy scope">
|
||||||
>Deploy scope<select bind:value={game} disabled={busy}
|
<Select bind:value={game} disabled={busy}
|
||||||
><option value="">Unscoped — select servers in a beta</option
|
><option value="">Unscoped — select servers in a beta</option
|
||||||
><option value="t5">Black Ops I</option><option value="t6"
|
><option value="t5">Black Ops I</option><option value="t6"
|
||||||
>Black Ops II</option
|
>Black Ops II</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -334,30 +336,11 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.lookup label {
|
|
||||||
|
.lookup :global(.field) {
|
||||||
flex: 1 1 300px;
|
flex: 1 1 300px;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
input:focus-visible,
|
|
||||||
select:focus-visible {
|
|
||||||
outline: 2px solid var(--accent-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.fields {
|
.fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
@@ -404,8 +387,4 @@
|
|||||||
font: var(--text-xs) var(--font-mono);
|
font: var(--text-xs) var(--font-mono);
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']):not([type='file']),
|
|
||||||
select {
|
|
||||||
border-color: var(--border-control);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { timestampISO } from '$lib/time-zone.js';
|
import { timestampISO } from '$lib/time-zone.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
@@ -125,23 +128,23 @@
|
|||||||
void load();
|
void load();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label class="search"
|
<Field class="search" label="Search activity">
|
||||||
>Search activity<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder="Find a stage or message"
|
placeholder="Find a stage or message"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Order">
|
||||||
>Order<select
|
<Select
|
||||||
bind:value={order}
|
bind:value={order}
|
||||||
onchange={() => {
|
onchange={() => {
|
||||||
void load();
|
void load();
|
||||||
}}
|
}}
|
||||||
><option value="newest">Newest first</option><option value="oldest"
|
><option value="newest">Newest first</option><option value="oldest"
|
||||||
>Oldest first</option
|
>Oldest first</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<Button type="submit" disabled={loading}>Search</Button>
|
<Button type="submit" disabled={loading}>Search</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -270,31 +273,11 @@
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
.tools label {
|
|
||||||
display: grid;
|
.tools :global(.search) {
|
||||||
gap: var(--space-2);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.search {
|
|
||||||
flex: 1 1 160px;
|
flex: 1 1 160px;
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select {
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 10px;
|
|
||||||
font: inherit;
|
|
||||||
min-height: 40px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
input:focus-visible,
|
|
||||||
select:focus-visible {
|
|
||||||
outline: 2px solid var(--border-focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.new-events {
|
.new-events {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -333,7 +316,7 @@
|
|||||||
.tools {
|
.tools {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.tools .search {
|
.tools :global(.search) {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 type { FleetSettings } from '$lib/fleet-settings.js';
|
import type { FleetSettings } from '$lib/fleet-settings.js';
|
||||||
let { settings }: { settings: FleetSettings } = $props();
|
let { settings }: { settings: FleetSettings } = $props();
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
@@ -141,12 +144,14 @@
|
|||||||
retry requests for that campaign; unfinished recovery still prevents cleanup.
|
retry requests for that campaign; unfinished recovery still prevents cleanup.
|
||||||
</p>
|
</p>
|
||||||
<div class="record">
|
<div class="record">
|
||||||
<label
|
<Field label="Category">
|
||||||
>Category<select bind:value={category}
|
<Select bind:value={category}
|
||||||
>{#each categories as value}<option {value}>{value}</option>{/each}</select
|
>{#each categories as value}<option {value}>{value}</option>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
|
>
|
||||||
|
<Field label="Record ID">
|
||||||
|
<Input bind:value={recordId} maxlength={200} /></Field
|
||||||
>
|
>
|
||||||
<label>Record ID<input bind:value={recordId} maxlength="200" /></label>
|
|
||||||
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
|
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
|
||||||
>Protect record</Button
|
>Protect record</Button
|
||||||
>
|
>
|
||||||
@@ -179,19 +184,7 @@
|
|||||||
code {
|
code {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
select,
|
|
||||||
input {
|
|
||||||
font: inherit;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 { goto } from '$app/navigation';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { plural } from '$ui/plural.js';
|
import { plural } from '$ui/plural.js';
|
||||||
@@ -306,8 +309,8 @@
|
|||||||
<a href="/scripts/{item.result.scriptId}">View script →</a>
|
<a href="/scripts/{item.result.scriptId}">View script →</a>
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<label
|
<Field label="Destination">
|
||||||
>Destination<select
|
<Select
|
||||||
bind:value={item.target}
|
bind:value={item.target}
|
||||||
disabled={busy || item.inspecting}
|
disabled={busy || item.inspecting}
|
||||||
onchange={() => (item.error = '')}
|
onchange={() => (item.error = '')}
|
||||||
@@ -320,10 +323,10 @@
|
|||||||
? ' · beta active'
|
? ' · beta active'
|
||||||
: ''}</option
|
: ''}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
{#if item.target === 'new'}<label
|
{#if item.target === 'new'}<Field label="Script name">
|
||||||
>Script name<input bind:value={item.name} disabled={busy} /></label
|
<Input bind:value={item.name} disabled={busy} /></Field
|
||||||
>
|
>
|
||||||
{:else if item.target !== ''}{@const s = scripts.find(
|
{:else if item.target !== ''}{@const s = scripts.find(
|
||||||
(s) => s.id === item.target
|
(s) => s.id === item.target
|
||||||
@@ -362,8 +365,8 @@
|
|||||||
></textarea></label
|
></textarea></label
|
||||||
>
|
>
|
||||||
{#if hasNew}
|
{#if hasNew}
|
||||||
<label
|
<Field label="Scope for new scripts">
|
||||||
>Scope for new scripts<select
|
<Select
|
||||||
bind:value={scopeType}
|
bind:value={scopeType}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onchange={() => {
|
onchange={() => {
|
||||||
@@ -372,28 +375,28 @@
|
|||||||
}}
|
}}
|
||||||
><option value="none">Unscoped · choose beta targets</option><option
|
><option value="none">Unscoped · choose beta targets</option><option
|
||||||
value="game">Game</option
|
value="game">Game</option
|
||||||
><option value="map">Map</option></select
|
><option value="map">Map</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{#if scopeType !== 'none'}
|
{#if scopeType !== 'none'}
|
||||||
<label
|
<Field label="Game">
|
||||||
>Game<select
|
<Select
|
||||||
bind:value={scopeGame}
|
bind:value={scopeGame}
|
||||||
disabled={busy || !scopeLoaded}
|
disabled={busy || !scopeLoaded}
|
||||||
onchange={() => (scopeMap = '')}
|
onchange={() => (scopeMap = '')}
|
||||||
><option value="">Choose a game…</option
|
><option value="">Choose a game…</option
|
||||||
>{#each scopeOptions.games as g}<option value={g}
|
>{#each scopeOptions.games as g}<option value={g}
|
||||||
>{gameTitle(g)}</option
|
>{gameTitle(g)}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{#if scopeType === 'map'}<label
|
{#if scopeType === 'map'}<Field label="Map">
|
||||||
>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}
|
<Select bind:value={scopeMap} disabled={busy || !scopeGame}
|
||||||
><option value="">Choose a map…</option
|
><option value="">Choose a map…</option
|
||||||
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option
|
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option
|
||||||
value={m}>{mapLabel(m)}</option
|
value={m}>{mapLabel(m)}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>{/if}
|
>{/if}
|
||||||
{#if scopeValue}<p class="note">
|
{#if scopeValue}<p class="note">
|
||||||
{matches.length} matching servers: {matches
|
{matches.length} matching servers: {matches
|
||||||
@@ -548,7 +551,6 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
input,
|
input,
|
||||||
select,
|
|
||||||
textarea {
|
textarea {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -565,7 +567,6 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
select:focus-visible,
|
|
||||||
textarea:focus-visible {
|
textarea:focus-visible {
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -622,7 +623,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']):not([type='file']),
|
input:not([type='checkbox']):not([type='file']),
|
||||||
select,
|
|
||||||
textarea {
|
textarea {
|
||||||
border-color: var(--border-control);
|
border-color: var(--border-control);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
@@ -155,18 +158,18 @@
|
|||||||
<svelte:window onbeforeunload={beforeUnload} />
|
<svelte:window onbeforeunload={beforeUnload} />
|
||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
<aside class="section-rail">
|
<aside class="section-rail">
|
||||||
<label class="search-label" for="settings-search">Find a setting</label>
|
<Field label="Find a setting" forId="settings-search">
|
||||||
<input
|
<Input
|
||||||
id="settings-search"
|
id="settings-search"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search settings"
|
placeholder="Search settings"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
oninput={() => (reviewInvalid = false)}
|
oninput={() => (reviewInvalid = false)}
|
||||||
/>
|
/></Field
|
||||||
<label class="mobile-section"
|
>
|
||||||
>Section
|
<Field class="mobile-section" label="Section">
|
||||||
<select
|
<Select
|
||||||
aria-label="Settings section"
|
aria-label="Settings section"
|
||||||
value={selectedGroup}
|
value={selectedGroup}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -177,8 +180,8 @@
|
|||||||
? ` · ${changed.filter((key) => group.keys.includes(key)).length} changed`
|
? ` · ${changed.filter((key) => group.keys.includes(key)).length} changed`
|
||||||
: ''}</option
|
: ''}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select>
|
</Select>
|
||||||
</label>
|
</Field>
|
||||||
<nav class="group-links" aria-label="Settings sections">
|
<nav class="group-links" aria-label="Settings sections">
|
||||||
{#each settingsGroups as group}
|
{#each settingsGroups as group}
|
||||||
{@const count = changed.filter((key) => group.keys.includes(key)).length}
|
{@const count = changed.filter((key) => group.keys.includes(key)).length}
|
||||||
@@ -263,30 +266,37 @@
|
|||||||
>
|
>
|
||||||
<td class="value-cell">
|
<td class="value-cell">
|
||||||
<div class="numeric-value">
|
<div class="numeric-value">
|
||||||
{#if unlimited}<input
|
{#if unlimited}<Field
|
||||||
id={`setting-${key}`}
|
><Input
|
||||||
type="number"
|
id={`setting-${key}`}
|
||||||
disabled
|
type="number"
|
||||||
placeholder="—"
|
disabled
|
||||||
aria-describedby={`setting-${key}-help setting-${key}-default`}
|
placeholder="—"
|
||||||
/>
|
aria-describedby={`setting-${key}-help setting-${key}-default`}
|
||||||
{:else}<input
|
/></Field
|
||||||
id={`setting-${key}`}
|
>
|
||||||
type="number"
|
{:else}<Field
|
||||||
min={Math.max(1, settingMinimum(key))}
|
><Input
|
||||||
max={key.endsWith('Days')
|
id={`setting-${key}`}
|
||||||
? 36500
|
type="number"
|
||||||
: undefined}
|
min={Math.max(
|
||||||
step="1"
|
1,
|
||||||
required
|
settingMinimum(key)
|
||||||
bind:value={draft[key]}
|
)}
|
||||||
aria-invalid={!!validation}
|
max={key.endsWith('Days')
|
||||||
aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`}
|
? 36500
|
||||||
oninput={() => {
|
: undefined}
|
||||||
error = '';
|
step="1"
|
||||||
notice = '';
|
required
|
||||||
}}
|
bind:value={draft[key]}
|
||||||
/>{/if}
|
aria-invalid={!!validation}
|
||||||
|
aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`}
|
||||||
|
oninput={() => {
|
||||||
|
error = '';
|
||||||
|
notice = '';
|
||||||
|
}}
|
||||||
|
/></Field
|
||||||
|
>{/if}
|
||||||
<span class="unit">{settingUnit(key)}</span>
|
<span class="unit">{settingUnit(key)}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if zeroMode}<label
|
{#if zeroMode}<label
|
||||||
@@ -393,13 +403,7 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.search-label {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
#settings-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.group-links {
|
.group-links {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
@@ -521,17 +525,15 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
.numeric-value input {
|
|
||||||
width: 88px;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 5px var(--space-2);
|
|
||||||
font-family: var(--font-numeric);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
input:disabled {
|
input:disabled {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
.numeric-value > :global(.field) {
|
||||||
|
width: 100px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
.unit {
|
.unit {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -646,7 +648,7 @@
|
|||||||
.notice {
|
.notice {
|
||||||
color: var(--state-online);
|
color: var(--state-online);
|
||||||
}
|
}
|
||||||
.mobile-section,
|
.section-rail :global(.mobile-section),
|
||||||
.mobile-label {
|
.mobile-label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -680,7 +682,7 @@
|
|||||||
.group-links {
|
.group-links {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.mobile-section {
|
.section-rail :global(.mobile-section) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -688,9 +690,6 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
.mobile-section select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import type {
|
import type {
|
||||||
RebalancePreview,
|
RebalancePreview,
|
||||||
@@ -254,15 +257,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
<label
|
<Field label="Game">
|
||||||
>Game<select bind:value={game} onchange={editSettings}
|
<Select bind:value={game} onchange={editSettings}
|
||||||
><option value="all">All games</option><option value="t5"
|
><option value="all">All games</option><option value="t5"
|
||||||
>Black Ops I</option
|
>Black Ops I</option
|
||||||
><option value="t6">Black Ops II</option></select
|
><option value="t6">Black Ops II</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field class="max-moves" label="Maximum moves">
|
||||||
>Maximum moves<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="20"
|
max="20"
|
||||||
@@ -270,7 +273,7 @@
|
|||||||
required
|
required
|
||||||
bind:value={maxMoves}
|
bind:value={maxMoves}
|
||||||
oninput={editSettings}
|
oninput={editSettings}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -301,16 +304,20 @@
|
|||||||
<section class="runs" aria-labelledby="rebalance-runs-title">
|
<section class="runs" aria-labelledby="rebalance-runs-title">
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
<h3 id="rebalance-runs-title">Rebalance runs</h3>
|
<h3 id="rebalance-runs-title">Rebalance runs</h3>
|
||||||
{#if report.runs.length}<label class="run-picker"
|
{#if report.runs.length}<Field
|
||||||
><span class="sr-only">Select rebalance run</span><select
|
class="run-picker"
|
||||||
|
hiddenLabel
|
||||||
|
label="Select rebalance run"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
value={run?.id ?? ''}
|
value={run?.id ?? ''}
|
||||||
onchange={(event) => (selectedRun = event.currentTarget.value)}
|
onchange={(event) => (selectedRun = event.currentTarget.value)}
|
||||||
>{#each report.runs as item (item.id)}<option value={item.id}
|
>{#each report.runs as item (item.id)}<option value={item.id}
|
||||||
>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[
|
>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[
|
||||||
item.state
|
item.state
|
||||||
]}</option
|
]}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>{/if}
|
>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if preferredRun && !report.runs.some((item) => item.id === preferredRun)}<p
|
{#if preferredRun && !report.runs.some((item) => item.id === preferredRun)}<p
|
||||||
@@ -422,37 +429,18 @@
|
|||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
.settings > :global(.max-moves) {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
.settings {
|
.settings {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.settings label {
|
|
||||||
display: grid;
|
input:disabled {
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.settings input {
|
|
||||||
width: 110px;
|
|
||||||
}
|
|
||||||
select,
|
|
||||||
input[type='number'] {
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 9px 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font: var(--text-sm) var(--font-body);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
input:disabled,
|
|
||||||
select:disabled {
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.run-picker {
|
.heading :global(.run-picker) {
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.run-picker select {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.runs {
|
.runs {
|
||||||
@@ -490,19 +478,11 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible {
|
||||||
select:focus-visible {
|
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.panel {
|
.panel {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setContext, type Snippet } from 'svelte';
|
import { setContext, type Snippet } from 'svelte';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||||
label?: string;
|
label?: string | Snippet;
|
||||||
|
hiddenLabel?: boolean;
|
||||||
hint?: string | null;
|
hint?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
@@ -11,11 +13,21 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
|
let {
|
||||||
|
label,
|
||||||
|
hiddenLabel = false,
|
||||||
|
hint = null,
|
||||||
|
error = null,
|
||||||
|
required = false,
|
||||||
|
forId,
|
||||||
|
children,
|
||||||
|
class: className,
|
||||||
|
...attributes
|
||||||
|
}: Props = $props();
|
||||||
const id = $props.id();
|
const id = $props.id();
|
||||||
setContext<FieldContext>(fieldContext, {
|
setContext<FieldContext>(fieldContext, {
|
||||||
get labelId() {
|
get labelId() {
|
||||||
return `${id}-label`;
|
return label ? `${id}-label` : undefined;
|
||||||
},
|
},
|
||||||
get messageId() {
|
get messageId() {
|
||||||
return error || hint ? `${id}-message` : undefined;
|
return error || hint ? `${id}-message` : undefined;
|
||||||
@@ -42,10 +54,15 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="field" use:connectLabel>
|
<div {...attributes} class={['field', className]} use:connectLabel>
|
||||||
{#if label}
|
{#if label}
|
||||||
<label class="label" id={`${id}-label`} for={forId ?? `${id}-control`}>
|
<label
|
||||||
{label}
|
class="label"
|
||||||
|
class:sr-only={hiddenLabel}
|
||||||
|
id={`${id}-label`}
|
||||||
|
for={forId ?? `${id}-control`}
|
||||||
|
>
|
||||||
|
{#if typeof label === 'string'}{label}{:else}{@render label()}{/if}
|
||||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -60,6 +77,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,61 +1,54 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" generics="Value extends string | number | undefined = string">
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||||
interface Props {
|
|
||||||
value?: string;
|
interface Props extends Omit<HTMLInputAttributes, 'value' | 'type'> {
|
||||||
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file';
|
value?: Value;
|
||||||
placeholder?: string;
|
type?:
|
||||||
id?: string;
|
| 'text'
|
||||||
name?: string;
|
| 'search'
|
||||||
required?: boolean;
|
| 'email'
|
||||||
disabled?: boolean;
|
| 'number'
|
||||||
readonly?: boolean;
|
| 'url'
|
||||||
autocomplete?: import('svelte/elements').FullAutoFill;
|
| 'password'
|
||||||
|
| 'file'
|
||||||
|
| 'tel'
|
||||||
|
| 'time'
|
||||||
|
| 'date'
|
||||||
|
| 'datetime-local'
|
||||||
|
| 'month'
|
||||||
|
| 'week';
|
||||||
mono?: boolean;
|
mono?: boolean;
|
||||||
onchange?: (e: Event) => void;
|
|
||||||
oninput?: (e: Event) => void;
|
|
||||||
accept?: string;
|
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
element?: HTMLInputElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(),
|
||||||
|
element = $bindable(),
|
||||||
type = 'text',
|
type = 'text',
|
||||||
placeholder,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
required = false,
|
|
||||||
disabled = false,
|
|
||||||
readonly = false,
|
|
||||||
autocomplete,
|
|
||||||
mono = false,
|
mono = false,
|
||||||
onchange,
|
ariaLabel,
|
||||||
oninput,
|
class: className,
|
||||||
accept,
|
...attributes
|
||||||
ariaLabel
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
class="input"
|
{...attributes}
|
||||||
class:mono
|
class={['input', className, { mono }]}
|
||||||
{type}
|
{type}
|
||||||
{id}
|
aria-label={ariaLabel ?? attributes['aria-label']}
|
||||||
{name}
|
aria-labelledby={attributes['aria-labelledby'] ??
|
||||||
{placeholder}
|
(ariaLabel || attributes['aria-label'] ? undefined : field?.labelId)}
|
||||||
{required}
|
aria-describedby={[attributes['aria-describedby'], field?.messageId]
|
||||||
{disabled}
|
.filter(Boolean)
|
||||||
{readonly}
|
.join(' ') || undefined}
|
||||||
{autocomplete}
|
aria-invalid={attributes['aria-invalid'] ?? (field?.invalid || undefined)}
|
||||||
{accept}
|
bind:this={element}
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
|
||||||
aria-describedby={field?.messageId}
|
|
||||||
aria-invalid={field?.invalid || undefined}
|
|
||||||
bind:value
|
bind:value
|
||||||
{onchange}
|
|
||||||
{oninput}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,51 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" generics="Value = string">
|
||||||
import { getContext } from 'svelte';
|
import { getContext, type Snippet } from 'svelte';
|
||||||
|
import type { HTMLSelectAttributes } from 'svelte/elements';
|
||||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: Value;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props extends Omit<HTMLSelectAttributes, 'value' | 'children' | 'multiple'> {
|
||||||
value?: string;
|
value?: Value;
|
||||||
options: Option[];
|
options?: Option[];
|
||||||
id?: string;
|
children?: Snippet;
|
||||||
name?: string;
|
|
||||||
required?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onchange?: (e: Event) => void;
|
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
element?: HTMLSelectElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(),
|
||||||
options,
|
element = $bindable(),
|
||||||
id,
|
options = [],
|
||||||
name,
|
children,
|
||||||
required = false,
|
|
||||||
disabled = false,
|
|
||||||
placeholder,
|
placeholder,
|
||||||
onchange,
|
ariaLabel,
|
||||||
ariaLabel
|
class: className,
|
||||||
|
...attributes
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<select
|
<select
|
||||||
class="select"
|
{...attributes}
|
||||||
{id}
|
class={['select', className]}
|
||||||
{name}
|
aria-label={ariaLabel ?? attributes['aria-label']}
|
||||||
{required}
|
aria-labelledby={attributes['aria-labelledby'] ??
|
||||||
{disabled}
|
(ariaLabel || attributes['aria-label'] ? undefined : field?.labelId)}
|
||||||
aria-label={ariaLabel}
|
aria-describedby={[attributes['aria-describedby'], field?.messageId]
|
||||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
.filter(Boolean)
|
||||||
aria-describedby={field?.messageId}
|
.join(' ') || undefined}
|
||||||
aria-invalid={field?.invalid || undefined}
|
aria-invalid={attributes['aria-invalid'] ?? (field?.invalid || undefined)}
|
||||||
|
bind:this={element}
|
||||||
bind:value
|
bind:value
|
||||||
{onchange}
|
|
||||||
>
|
>
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
<option value="" disabled>{placeholder}</option>
|
<option value="" disabled>{placeholder}</option>
|
||||||
@@ -53,6 +50,7 @@
|
|||||||
{#each options as o (o.value)}
|
{#each options as o (o.value)}
|
||||||
<option value={o.value} disabled={o.disabled}>{o.label}</option>
|
<option value={o.value} disabled={o.disabled}>{o.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
{@render children?.()}
|
||||||
</select>
|
</select>
|
||||||
<span class="chev" aria-hidden="true">▾</span>
|
<span class="chev" aria-hidden="true">▾</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const fieldContext = Symbol('field');
|
export const fieldContext = Symbol('field');
|
||||||
export interface FieldContext {
|
export interface FieldContext {
|
||||||
readonly labelId: string;
|
readonly labelId: string | undefined;
|
||||||
readonly messageId: string | undefined;
|
readonly messageId: string | undefined;
|
||||||
readonly invalid: boolean;
|
readonly invalid: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-16
@@ -8,20 +8,8 @@
|
|||||||
flex: 1 1 260px;
|
flex: 1 1 260px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.library-toolbar input,
|
.library-toolbar > .field:not(.search) {
|
||||||
.library-toolbar select {
|
flex: 0 1 auto;
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 40px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font: var(--text-sm) var(--font-body);
|
|
||||||
}
|
|
||||||
.library-toolbar > select {
|
|
||||||
width: auto;
|
|
||||||
}
|
}
|
||||||
.library-table {
|
.library-table {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -93,8 +81,7 @@
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.library-toolbar > select,
|
.library-toolbar > .field:not(.search) {
|
||||||
.library-toolbar > label {
|
|
||||||
flex: 1 1 140px;
|
flex: 1 1 140px;
|
||||||
}
|
}
|
||||||
.library-toolbar .search {
|
.library-toolbar .search {
|
||||||
|
|||||||
@@ -47,11 +47,10 @@ dialog::backdrop {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
.ui-toolbar .search,
|
.ui-toolbar > .search {
|
||||||
.ui-toolbar > input[type='search'] {
|
|
||||||
flex: 1 1 240px;
|
flex: 1 1 240px;
|
||||||
}
|
}
|
||||||
.ui-toolbar select {
|
.ui-toolbar > .field:not(.search) {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
.ui-summary {
|
.ui-summary {
|
||||||
@@ -114,12 +113,11 @@ dialog::backdrop {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.ui-toolbar .search,
|
.ui-toolbar > .search {
|
||||||
.ui-toolbar > input[type='search'] {
|
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.ui-toolbar select {
|
.ui-toolbar > .field:not(.search) {
|
||||||
flex: 1 1 130px;
|
flex: 1 1 130px;
|
||||||
}
|
}
|
||||||
.ui-sticky-actions {
|
.ui-sticky-actions {
|
||||||
|
|||||||
+22
-31
@@ -1,4 +1,7 @@
|
|||||||
<script module lang="ts">
|
<script module lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
const serverTitleCollator = new Intl.Collator(undefined, { numeric: true });
|
const serverTitleCollator = new Intl.Collator(undefined, { numeric: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -443,12 +446,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<label class="search"
|
<Field class="search" hiddenLabel label="Search servers, maps, or players">
|
||||||
><span class="sr-only">Search servers, maps, or players</span><input
|
<Input type="search" bind:value={query} placeholder="Search fleet…" /></Field
|
||||||
type="search"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder="Search fleet…"
|
|
||||||
/></label
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="control"
|
class="control"
|
||||||
@@ -459,27 +458,27 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if viewOptionsOpen}<div id="fleet-view-options" class="view-options">
|
{#if viewOptionsOpen}<div id="fleet-view-options" class="view-options">
|
||||||
<label
|
<Field label="Game">
|
||||||
>Game<select bind:value={game}
|
<Select bind:value={game}
|
||||||
><option value="">All games</option><option value="t5"
|
><option value="">All games</option><option value="t5"
|
||||||
>Black Ops I</option
|
>Black Ops I</option
|
||||||
><option value="t6">Black Ops II</option></select
|
><option value="t6">Black Ops II</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Group by">
|
||||||
>Group by<select bind:value={grouping}
|
<Select bind:value={grouping}
|
||||||
><option value="none">None</option><option value="map">Map</option
|
><option value="none">None</option><option value="map">Map</option
|
||||||
><option value="game">Game</option></select
|
><option value="game">Game</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Sort by">
|
||||||
>Sort by<select bind:value={sort}
|
<Select bind:value={sort}
|
||||||
><option value="duration">Longest match</option><option value="players"
|
><option value="duration">Longest match</option><option value="players"
|
||||||
>Most players</option
|
>Most players</option
|
||||||
><option value="round">Highest round</option><option value="name"
|
><option value="round">Highest round</option><option value="name"
|
||||||
>Lobby name</option
|
>Lobby name</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<div class="view-toggle" role="group" aria-label="Display mode">
|
<div class="view-toggle" role="group" aria-label="Display mode">
|
||||||
<button
|
<button
|
||||||
@@ -698,8 +697,7 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
button,
|
button,
|
||||||
input,
|
input {
|
||||||
select {
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
@@ -750,15 +748,11 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.search {
|
.search-row :global(.search) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.search input {
|
input {
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
border: 1px solid var(--border-control);
|
border: 1px solid var(--border-control);
|
||||||
@@ -790,12 +784,9 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
.view-options label {
|
|
||||||
display: grid;
|
.view-options :global(.field) {
|
||||||
gap: var(--space-2);
|
|
||||||
flex: 1 1 130px;
|
flex: 1 1 130px;
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
}
|
||||||
.chips,
|
.chips,
|
||||||
.view-toggle {
|
.view-toggle {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
|
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
|
||||||
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
|
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
|
||||||
@@ -105,53 +108,57 @@
|
|||||||
</p>{/if}
|
</p>{/if}
|
||||||
<form onsubmit={changePassword} aria-busy={busy}>
|
<form onsubmit={changePassword} aria-busy={busy}>
|
||||||
<fieldset disabled={busy || signingOut}>
|
<fieldset disabled={busy || signingOut}>
|
||||||
<input
|
<Field label="Username" hiddenLabel class="password-username"
|
||||||
type="text"
|
><Input
|
||||||
class="password-username"
|
type="text"
|
||||||
value={user.username}
|
value={user.username}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
aria-label="Username"
|
aria-label="Username"
|
||||||
readonly
|
readonly
|
||||||
tabindex="-1"
|
tabindex={-1}
|
||||||
/>
|
/></Field
|
||||||
<label for="current-password"
|
>
|
||||||
>{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input
|
<Field
|
||||||
|
forId="current-password"
|
||||||
|
label={user.mustChangePassword ? 'Temporary password' : 'Current password'}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
id="current-password"
|
id="current-password"
|
||||||
name="currentPassword"
|
name="currentPassword"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
maxlength="128"
|
maxlength={128}
|
||||||
bind:value={currentPassword}
|
bind:value={currentPassword}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label for="new-password"
|
<Field forId="new-password" label="New password">
|
||||||
>New password<input
|
<Input
|
||||||
id="new-password"
|
id="new-password"
|
||||||
name="newPassword"
|
name="newPassword"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength={8}
|
||||||
maxlength="128"
|
maxlength={128}
|
||||||
bind:value={newPassword}
|
bind:value={newPassword}
|
||||||
aria-describedby="new-password-help"
|
aria-describedby="new-password-help"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<p id="new-password-help" class="hint">
|
<p id="new-password-help" class="hint">
|
||||||
Use 8–128 characters and a password you do not use elsewhere.
|
Use 8–128 characters and a password you do not use elsewhere.
|
||||||
</p>
|
</p>
|
||||||
<label for="confirm-password"
|
<Field forId="confirm-password" label="Confirm new password">
|
||||||
>Confirm new password<input
|
<Input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength={8}
|
||||||
maxlength="128"
|
maxlength={128}
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
@@ -169,16 +176,15 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="panel" aria-labelledby="display-heading">
|
<section class="panel" aria-labelledby="display-heading">
|
||||||
<h2 id="display-heading">Display preferences</h2>
|
<h2 id="display-heading">Display preferences</h2>
|
||||||
<label class="timezone"
|
<Field class="timezone" label="Timezone">
|
||||||
>Timezone
|
<Select bind:value={$timeZone} aria-describedby="timezone-help">
|
||||||
<select bind:value={$timeZone} aria-describedby="timezone-help">
|
|
||||||
{#each zones as zone}<option value={zone}
|
{#each zones as zone}<option value={zone}
|
||||||
>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE
|
>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE
|
||||||
? ' (default)'
|
? ' (default)'
|
||||||
: ''}</option
|
: ''}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select>
|
</Select>
|
||||||
</label>
|
</Field>
|
||||||
<p id="timezone-help">
|
<p id="timezone-help">
|
||||||
Dates and times throughout the app use this timezone, including daylight saving
|
Dates and times throughout the app use this timezone, including daylight saving
|
||||||
changes. Saved automatically in this browser.
|
changes. Saved automatically in this browser.
|
||||||
@@ -193,23 +199,14 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.timezone {
|
.panel :global(.timezone) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
.timezone select {
|
.panel :global(.timezone ~ p) {
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font: inherit;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.timezone ~ p {
|
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -263,21 +260,8 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
.panel :global(.password-username) {
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.password-username {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
@@ -215,8 +218,8 @@
|
|||||||
>
|
>
|
||||||
<fieldset disabled={busy}>
|
<fieldset disabled={busy}>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label
|
<Field label="Game">
|
||||||
>Game<select
|
<Select
|
||||||
bind:value={settings.game}
|
bind:value={settings.game}
|
||||||
disabled={!!editing}
|
disabled={!!editing}
|
||||||
onchange={() => {
|
onchange={() => {
|
||||||
@@ -225,60 +228,60 @@
|
|||||||
}}
|
}}
|
||||||
><option value="t5">Black Ops I</option><option value="t6"
|
><option value="t5">Black Ops I</option><option value="t6"
|
||||||
>Black Ops II</option
|
>Black Ops II</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Scope">
|
||||||
>Scope<select bind:value={settings.mapSlug} disabled={!!editing}
|
<Select bind:value={settings.mapSlug} disabled={!!editing}
|
||||||
><option value="*">Every map in this game</option
|
><option value="*">Every map in this game</option
|
||||||
>{#each zombieMaps(settings.game) as map}<option value={map.slug}
|
>{#each zombieMaps(settings.game) as map}<option value={map.slug}
|
||||||
>{map.friendly}</option
|
>{map.friendly}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Autoscaling">
|
||||||
>Autoscaling<select bind:value={settings.mode}
|
<Select bind:value={settings.mode}
|
||||||
><option value="off">Off</option
|
><option value="off">Off</option
|
||||||
>{#if report.removalAvailable}<option value="create-destroy"
|
>{#if report.removalAvailable}<option value="create-destroy"
|
||||||
>On</option
|
>On</option
|
||||||
>{/if}</select
|
>{/if}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Minimum running lobbies">
|
||||||
>Minimum running lobbies<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="20"
|
max="20"
|
||||||
required
|
required
|
||||||
bind:value={settings.minimum}
|
bind:value={settings.minimum}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Maximum lobbies">
|
||||||
>Maximum lobbies<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="20"
|
max="20"
|
||||||
required
|
required
|
||||||
bind:value={settings.maximum}
|
bind:value={settings.maximum}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Empty spare target">
|
||||||
>Empty spare target<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="20"
|
max="20"
|
||||||
required
|
required
|
||||||
bind:value={settings.spares}
|
bind:value={settings.spares}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Empty duration in minutes">
|
||||||
>Empty duration in minutes<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="1440"
|
max="1440"
|
||||||
required
|
required
|
||||||
bind:value={settings.emptyMinutes}
|
bind:value={settings.emptyMinutes}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if settings.mapSlug === '*'}<p>
|
{#if settings.mapSlug === '*'}<p>
|
||||||
@@ -356,22 +359,7 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
padding: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
fieldset {
|
fieldset {
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
@@ -158,8 +161,8 @@
|
|||||||
ariaLabel="Beta views"
|
ariaLabel="Beta views"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label class="search"
|
<Field class="search" label="Search">
|
||||||
>Search<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
oninput={() => {
|
oninput={() => {
|
||||||
@@ -167,11 +170,11 @@
|
|||||||
saveContext();
|
saveContext();
|
||||||
}}
|
}}
|
||||||
placeholder="Name, beta number, file, or server"
|
placeholder="Name, beta number, file, or server"
|
||||||
maxlength="200"
|
maxlength={200}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Status">
|
||||||
>Status<select
|
<Select
|
||||||
bind:value={status}
|
bind:value={status}
|
||||||
onchange={() => {
|
onchange={() => {
|
||||||
pageNumber = 1;
|
pageNumber = 1;
|
||||||
@@ -187,7 +190,7 @@
|
|||||||
{:else}<option value="promoted">Promoted</option><option value="rolled-back"
|
{:else}<option value="promoted">Promoted</option><option value="rolled-back"
|
||||||
>Rolled back</option
|
>Rolled back</option
|
||||||
>{/if}
|
>{/if}
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
{#if betas.length && (search || status)}<Button
|
{#if betas.length && (search || status)}<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -320,27 +323,12 @@
|
|||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
.toolbar :global(.search) {
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.search {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select {
|
|
||||||
padding: 10px 12px;
|
|
||||||
min-height: 40px;
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.table-scroll {
|
.table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -444,7 +432,7 @@
|
|||||||
td {
|
td {
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
}
|
}
|
||||||
.search {
|
.toolbar :global(.search) {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
.pagination {
|
.pagination {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { countLabel } from '$ui/plural.js';
|
import { countLabel } from '$ui/plural.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
|
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
|
||||||
@@ -667,29 +670,30 @@
|
|||||||
<form onsubmit={openBeta} class="beta-form">
|
<form onsubmit={openBeta} class="beta-form">
|
||||||
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
|
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
|
||||||
{#if step === 0}
|
{#if step === 0}
|
||||||
<label
|
<Field label="Beta label (optional)">
|
||||||
>Beta label (optional)<input
|
<Input
|
||||||
bind:value={betaLabel}
|
bind:value={betaLabel}
|
||||||
maxlength="120"
|
maxlength={120}
|
||||||
placeholder="For example, Town balance update"
|
placeholder="For example, Town balance update"
|
||||||
/><span class="muted"
|
/><span class="muted"
|
||||||
>Shown in the beta list so others can identify this test.</span
|
>Shown in the beta list so others can identify this test.</span
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<div class="picks">
|
<div class="picks">
|
||||||
{#each picks as pick, index (pick.key)}<section class="pick-row">
|
{#each picks as pick, index (pick.key)}<section class="pick-row">
|
||||||
<label
|
<Field
|
||||||
>File {index + 1}<select
|
>{#snippet label()}File {index + 1}{/snippet}
|
||||||
|
<Select
|
||||||
bind:value={pick.scriptId}
|
bind:value={pick.scriptId}
|
||||||
onchange={() => onPickFile(index)}
|
onchange={() => onPickFile(index)}
|
||||||
><option value={null}>Choose a file</option
|
><option value={null}>Choose a file</option
|
||||||
>{#each eligibleFor(index) as file}<option value={file.id}
|
>{#each eligibleFor(index) as file}<option value={file.id}
|
||||||
>[{file.kind}] {file.name}</option
|
>[{file.kind}] {file.name}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Exact version">
|
||||||
>Exact version<select
|
<Select
|
||||||
bind:value={pick.versionId}
|
bind:value={pick.versionId}
|
||||||
disabled={pick.scriptId === null ||
|
disabled={pick.scriptId === null ||
|
||||||
loadingVersions.has(pick.key)}
|
loadingVersions.has(pick.key)}
|
||||||
@@ -702,8 +706,8 @@
|
|||||||
0,
|
0,
|
||||||
8
|
8
|
||||||
)} · {version.state.replaceAll('-', ' ')}</option
|
)} · {version.state.replaceAll('-', ' ')}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
ariaLabel={`Remove file ${index + 1}`}
|
ariaLabel={`Remove file ${index + 1}`}
|
||||||
@@ -742,8 +746,8 @@
|
|||||||
At launch, Electric Cherry chooses compatible servers with the fewest
|
At launch, Electric Cherry chooses compatible servers with the fewest
|
||||||
current players. Every requested instance must pass the test plan.
|
current players. Every requested instance must pass the test plan.
|
||||||
</p>
|
</p>
|
||||||
<label class="instance-count"
|
<Field class="instance-count" label="Instances per game">
|
||||||
>Instances per game<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="1000"
|
max="1000"
|
||||||
@@ -756,7 +760,7 @@
|
|||||||
: event.currentTarget.valueAsNumber)}
|
: event.currentTarget.valueAsNumber)}
|
||||||
/><span class="muted"
|
/><span class="muted"
|
||||||
>The same number applies to every game in this beta.</span
|
>The same number applies to every game in this beta.</span
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
{applicableGames
|
{applicableGames
|
||||||
@@ -812,14 +816,14 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
{#if selectionMode === 'manual' && candidateServers.length}
|
{#if selectionMode === 'manual' && candidateServers.length}
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label
|
<Field label="Find servers">
|
||||||
>Find servers<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
placeholder="Name, map, lobby…"
|
placeholder="Name, map, lobby…"
|
||||||
/></label
|
/></Field
|
||||||
><label
|
><Field label="Game">
|
||||||
>Game<select bind:value={serverGame}
|
<Select bind:value={serverGame}
|
||||||
><option value="">All games</option
|
><option value="">All games</option
|
||||||
>{#each [...new Set(fleetServers
|
>{#each [...new Set(fleetServers
|
||||||
.map((s) => s.game)
|
.map((s) => s.game)
|
||||||
@@ -829,16 +833,16 @@
|
|||||||
: game === 't6'
|
: game === 't6'
|
||||||
? 'Black Ops II'
|
? 'Black Ops II'
|
||||||
: game}</option
|
: game}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
><label
|
><Field label="Availability">
|
||||||
>Availability<select bind:value={availability}
|
<Select bind:value={availability}
|
||||||
><option value="">All servers</option><option value="empty"
|
><option value="">All servers</option><option value="empty"
|
||||||
>Confirmed empty</option
|
>Confirmed empty</option
|
||||||
><option value="occupied">Occupied</option><option
|
><option value="occupied">Occupied</option><option
|
||||||
value="unknown">Unknown / stale / offline</option
|
value="unknown">Unknown / stale / offline</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -999,8 +1003,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.beta-form input:not([type='checkbox']):not([type='radio']),
|
.beta-form input:not([type='checkbox']):not([type='radio']) {
|
||||||
.beta-form select {
|
|
||||||
border-color: var(--border-control);
|
border-color: var(--border-control);
|
||||||
}
|
}
|
||||||
.page,
|
.page,
|
||||||
@@ -1081,8 +1084,7 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']):not([type='radio']),
|
input:not([type='checkbox']):not([type='radio']) {
|
||||||
select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
@@ -1108,7 +1110,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.instance-count {
|
.automatic-selection :global(.instance-count) {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
.advanced-selection {
|
.advanced-selection {
|
||||||
@@ -1148,7 +1150,8 @@
|
|||||||
.filters {
|
.filters {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.filters label {
|
|
||||||
|
.filters :global(.field) {
|
||||||
flex: 1 1 180px;
|
flex: 1 1 180px;
|
||||||
}
|
}
|
||||||
.draft-status,
|
.draft-status,
|
||||||
@@ -1217,7 +1220,8 @@
|
|||||||
.pick-row {
|
.pick-row {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
.pick-row label:first-child {
|
|
||||||
|
.pick-row > :global(.field:first-child) {
|
||||||
grid-column: 1/-1;
|
grid-column: 1/-1;
|
||||||
}
|
}
|
||||||
.steps {
|
.steps {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
@@ -102,31 +105,31 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Campaigns" />
|
<SectionHeader title="Campaigns" />
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label class="search"
|
<Field class="search" label="Search">
|
||||||
>Search<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
oninput={() => saveContext(true)}
|
oninput={() => saveContext(true)}
|
||||||
placeholder="Script, campaign, or server"
|
placeholder="Script, campaign, or server"
|
||||||
maxlength="100"
|
maxlength={100}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Status">
|
||||||
>Status<select bind:value={status} onchange={() => saveContext(true)}>
|
<Select bind:value={status} onchange={() => saveContext(true)}>
|
||||||
<option value="">All statuses</option><option value="progress">In progress</option
|
<option value="">All statuses</option><option value="progress">In progress</option
|
||||||
><option value="attention">Needs attention</option><option value="complete"
|
><option value="attention">Needs attention</option><option value="complete"
|
||||||
>Complete</option
|
>Complete</option
|
||||||
><option value="rolled-back">Rolled back</option><option value="inactive"
|
><option value="rolled-back">Rolled back</option><option value="inactive"
|
||||||
>Inactive</option
|
>Inactive</option
|
||||||
>
|
>
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Type">
|
||||||
>Type<select bind:value={action} onchange={() => saveContext(true)}>
|
<Select bind:value={action} onchange={() => saveContext(true)}>
|
||||||
<option value="">All types</option><option value="promote">Promote</option><option
|
<option value="">All types</option><option value="promote">Promote</option><option
|
||||||
value="rollback">Rollback</option
|
value="rollback">Rollback</option
|
||||||
><option value="drift">Maintenance repair</option>
|
><option value="drift">Maintenance repair</option>
|
||||||
</select></label
|
</Select></Field
|
||||||
>
|
>
|
||||||
{#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if}
|
{#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -254,29 +257,12 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
.toolbar :global(.search) {
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.search {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select {
|
|
||||||
padding: 10px 12px;
|
|
||||||
min-height: 40px;
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input:focus-visible,
|
|
||||||
select:focus-visible,
|
|
||||||
.timestamp:focus-visible {
|
.timestamp:focus-visible {
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -438,10 +424,11 @@
|
|||||||
td {
|
td {
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
}
|
}
|
||||||
.search {
|
.toolbar :global(.search) {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
.toolbar > label:not(.search) {
|
|
||||||
|
.toolbar > :global(.field:not(.search)) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { gameTitle } from '$ui/server-labels.js';
|
import { gameTitle } from '$ui/server-labels.js';
|
||||||
import BackLink from '$ui/components/BackLink.svelte';
|
import BackLink from '$ui/components/BackLink.svelte';
|
||||||
@@ -716,15 +718,15 @@
|
|||||||
downloaded automatically.
|
downloaded automatically.
|
||||||
</p>
|
</p>
|
||||||
<div class="download">
|
<div class="download">
|
||||||
<label
|
<Field label="Host">
|
||||||
>Host<select bind:value={targets[item.id]}
|
<Select bind:value={targets[item.id]}
|
||||||
><option value="">Select host</option>{#each onlineHosts as host}<option
|
><option value="">Select host</option>{#each onlineHosts as host}<option
|
||||||
value={host.id}
|
value={host.id}
|
||||||
>{host.name} · {formatGameBytes(
|
>{host.name} · {formatGameBytes(
|
||||||
Number(host.inventory.storageAvailable ?? 0)
|
Number(host.inventory.storageAvailable ?? 0)
|
||||||
)} free</option
|
)} free</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
><Button
|
><Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !targets[item.id]}
|
disabled={busy || !targets[item.id]}
|
||||||
@@ -783,8 +785,7 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
input,
|
input {
|
||||||
select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -794,8 +795,7 @@
|
|||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
input:disabled,
|
input:disabled {
|
||||||
select:disabled {
|
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
input[type='file'] {
|
input[type='file'] {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
@@ -109,21 +112,26 @@
|
|||||||
>{/if}
|
>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if filtersVisible}<div class="filters">
|
{#if filtersVisible}<div class="filters">
|
||||||
<input
|
<Field label="Search hosts" hiddenLabel class="search"
|
||||||
type="search"
|
><Input
|
||||||
aria-label="Search hosts"
|
type="search"
|
||||||
placeholder="Search hosts…"
|
aria-label="Search hosts"
|
||||||
bind:value={search}
|
placeholder="Search hosts…"
|
||||||
/>
|
bind:value={search}
|
||||||
<select aria-label="Filter hosts by status" bind:value={status}
|
/></Field
|
||||||
><option value="">All statuses</option><option value="attention"
|
>
|
||||||
>Needs attention</option
|
<Field label="Filter hosts by status" hiddenLabel
|
||||||
><option value="offline">Offline</option><option value="preparing"
|
><Select aria-label="Filter hosts by status" bind:value={status}
|
||||||
>Preparing</option
|
><option value="">All statuses</option><option value="attention"
|
||||||
><option value="draining">Draining</option><option value="disabled"
|
>Needs attention</option
|
||||||
>Disabled</option
|
><option value="offline">Offline</option><option value="preparing"
|
||||||
><option value="deleting">Deleting</option><option value="ready">Ready</option
|
>Preparing</option
|
||||||
></select
|
><option value="draining">Draining</option><option value="disabled"
|
||||||
|
>Disabled</option
|
||||||
|
><option value="deleting">Deleting</option><option value="ready"
|
||||||
|
>Ready</option
|
||||||
|
></Select
|
||||||
|
></Field
|
||||||
>
|
>
|
||||||
{#if search || status}<Button
|
{#if search || status}<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -170,27 +178,15 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select {
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 10px 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font: var(--text-sm) var(--font-body);
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
flex: 1 1 240px;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 440px;
|
|
||||||
}
|
|
||||||
input:focus-visible,
|
|
||||||
select:focus-visible,
|
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
.filters > :global(.search) {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
.empty {
|
.empty {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||||
@@ -468,11 +470,8 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<fieldset disabled={busy || cleaning}>
|
<fieldset disabled={busy || cleaning}>
|
||||||
<label
|
<Field label="Plutainer image">
|
||||||
>Plutainer image<input
|
<Input required bind:value={runtimeImage} /></Field
|
||||||
required
|
|
||||||
bind:value={runtimeImage}
|
|
||||||
/></label
|
|
||||||
><Button type="submit" disabled={busy || !host.connected}
|
><Button type="submit" disabled={busy || !host.connected}
|
||||||
>Pull runtime image</Button
|
>Pull runtime image</Button
|
||||||
>
|
>
|
||||||
@@ -509,50 +508,43 @@
|
|||||||
<h2>Host configuration</h2>
|
<h2>Host configuration</h2>
|
||||||
{#if canManage}<form onsubmit={save}>
|
{#if canManage}<form onsubmit={save}>
|
||||||
<fieldset disabled={busy || cleaning}>
|
<fieldset disabled={busy || cleaning}>
|
||||||
<label
|
<Field label="Friendly name">
|
||||||
>Friendly name<input
|
<Input required maxlength={200} bind:value={name} /></Field
|
||||||
required
|
|
||||||
maxlength="200"
|
|
||||||
bind:value={name}
|
|
||||||
/></label
|
|
||||||
>
|
>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label
|
<Field label="First game port">
|
||||||
>First game port<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
required
|
required
|
||||||
bind:value={portMin}
|
bind:value={portMin}
|
||||||
/></label
|
/></Field
|
||||||
><label
|
><Field label="Last game port">
|
||||||
>Last game port<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={portMin}
|
min={portMin}
|
||||||
max="65535"
|
max="65535"
|
||||||
required
|
required
|
||||||
bind:value={portMax}
|
bind:value={portMax}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<label
|
<Field label="Local bind address">
|
||||||
>Local bind address<input
|
<Input required bind:value={bindAddress} /></Field
|
||||||
required
|
><Field label="Public / NAT address">
|
||||||
bind:value={bindAddress}
|
<Input
|
||||||
/></label
|
|
||||||
><label
|
|
||||||
>Public / NAT address<input
|
|
||||||
bind:value={publicAddress}
|
bind:value={publicAddress}
|
||||||
placeholder="Optional public hostname or address"
|
placeholder="Optional public hostname or address"
|
||||||
/></label
|
/></Field
|
||||||
><label
|
><Field label="Minimum free game storage (GiB)">
|
||||||
>Minimum free game storage (GiB)<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
required
|
required
|
||||||
bind:value={minimumFreeGB}
|
bind:value={minimumFreeGB}
|
||||||
/></label
|
/></Field
|
||||||
><Button type="submit" disabled={busy}>Save settings</Button>
|
><Button type="submit" disabled={busy}>Save settings</Button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>{:else}<dl>
|
</form>{:else}<dl>
|
||||||
@@ -745,20 +737,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 10px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.fields > * {
|
.fields > * {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
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 RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
let { data: initial }: { data: import('./$types').PageData } = $props();
|
let { data: initial }: { data: import('./$types').PageData } = $props();
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
@@ -262,9 +265,8 @@
|
|||||||
|
|
||||||
{#snippet filterFields(suffix: string)}
|
{#snippet filterFields(suffix: string)}
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<div class="field">
|
<Field label="Game" forId={`leaderboard-game${suffix}`}>
|
||||||
<label for={`leaderboard-game${suffix}`}>Game</label>
|
<Select
|
||||||
<select
|
|
||||||
id={`leaderboard-game${suffix}`}
|
id={`leaderboard-game${suffix}`}
|
||||||
value={game}
|
value={game}
|
||||||
onchange={(event) =>
|
onchange={(event) =>
|
||||||
@@ -273,11 +275,10 @@
|
|||||||
<option value="">All games</option>{#each games as item (item.key)}<option
|
<option value="">All games</option>{#each games as item (item.key)}<option
|
||||||
value={item.key}>{item.label}</option
|
value={item.key}>{item.label}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
<div class="field">
|
<Field label="Map" forId={`leaderboard-map${suffix}`}>
|
||||||
<label for={`leaderboard-map${suffix}`}>Map</label>
|
<Select
|
||||||
<select
|
|
||||||
id={`leaderboard-map${suffix}`}
|
id={`leaderboard-map${suffix}`}
|
||||||
value={map}
|
value={map}
|
||||||
onchange={(event) => updateFilters({ map: event.currentTarget.value })}
|
onchange={(event) => updateFilters({ map: event.currentTarget.value })}
|
||||||
@@ -291,11 +292,10 @@
|
|||||||
? ` · ${item.game === 't5' ? 'BO1' : 'BO2'}`
|
? ` · ${item.game === 't5' ? 'BO1' : 'BO2'}`
|
||||||
: ''}</option
|
: ''}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
<div class="field">
|
<Field label="Players" forId={`leaderboard-players${suffix}`}>
|
||||||
<label for={`leaderboard-players${suffix}`}>Players</label>
|
<Select
|
||||||
<select
|
|
||||||
id={`leaderboard-players${suffix}`}
|
id={`leaderboard-players${suffix}`}
|
||||||
value={players}
|
value={players}
|
||||||
onchange={(event) => updateFilters({ players: event.currentTarget.value })}
|
onchange={(event) => updateFilters({ players: event.currentTarget.value })}
|
||||||
@@ -304,14 +304,13 @@
|
|||||||
<option value="">All squad sizes</option>{#each squadSizes as size}<option
|
<option value="">All squad sizes</option>{#each squadSizes as size}<option
|
||||||
value={size.key}>{size.label}</option
|
value={size.key}>{size.label}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select><span id={`players-help${suffix}`} class="sr-only"
|
</Select><span id={`players-help${suffix}`} class="sr-only"
|
||||||
>Players recorded at the start of each match. Matches with unknown starting
|
>Players recorded at the start of each match. Matches with unknown starting
|
||||||
counts appear only under All squad sizes.</span
|
counts appear only under All squad sizes.</span
|
||||||
>
|
>
|
||||||
</div>
|
</Field>
|
||||||
<div class="field">
|
<Field label="Rank by" forId={`leaderboard-metric${suffix}`}>
|
||||||
<label for={`leaderboard-metric${suffix}`}>Rank by</label>
|
<Select
|
||||||
<select
|
|
||||||
id={`leaderboard-metric${suffix}`}
|
id={`leaderboard-metric${suffix}`}
|
||||||
value={metric}
|
value={metric}
|
||||||
onchange={(event) => updateFilters({ metric: event.currentTarget.value })}
|
onchange={(event) => updateFilters({ metric: event.currentTarget.value })}
|
||||||
@@ -319,8 +318,8 @@
|
|||||||
{#each leaderboardMetrics as item (item.key)}<option value={item.key}
|
{#each leaderboardMetrics as item (item.key)}<option value={item.key}
|
||||||
>{item.label}</option
|
>{item.label}</option
|
||||||
>{/each}
|
>{/each}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
<form
|
<form
|
||||||
class="search"
|
class="search"
|
||||||
onsubmit={(event) => {
|
onsubmit={(event) => {
|
||||||
@@ -330,15 +329,16 @@
|
|||||||
}}
|
}}
|
||||||
role="search"
|
role="search"
|
||||||
>
|
>
|
||||||
<label for={`leaderboard-search${suffix}`}>Find a player</label>
|
<Field label="Find a player" forId={`leaderboard-search${suffix}`}>
|
||||||
<input
|
<Input
|
||||||
id={`leaderboard-search${suffix}`}
|
id={`leaderboard-search${suffix}`}
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
maxlength="100"
|
maxlength={100}
|
||||||
placeholder="Name or GUID"
|
placeholder="Name or GUID"
|
||||||
aria-describedby={`search-help${suffix}`}
|
aria-describedby={`search-help${suffix}`}
|
||||||
/><span id={`search-help${suffix}`} class="sr-only"
|
/></Field
|
||||||
|
><span id={`search-help${suffix}`} class="sr-only"
|
||||||
>Press Enter to search. Player ranks are preserved.</span
|
>Press Enter to search. Player ranks are preserved.</span
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
@@ -700,7 +700,6 @@
|
|||||||
.search {
|
.search {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.field,
|
|
||||||
.search {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -712,12 +711,10 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
input,
|
input,
|
||||||
select,
|
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
input,
|
input {
|
||||||
select {
|
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { playerCompletionLabel } from '$ui/player-match.js';
|
import { playerCompletionLabel } from '$ui/player-match.js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -389,17 +391,17 @@
|
|||||||
description="Choose the leaderboard ranking and match history. Lifetime records stay unchanged."
|
description="Choose the leaderboard ranking and match history. Lifetime records stay unchanged."
|
||||||
>
|
>
|
||||||
<div class="scope-controls" aria-busy={filtering}>
|
<div class="scope-controls" aria-busy={filtering}>
|
||||||
<label
|
<Field label="Game">
|
||||||
>Game<select
|
<Select
|
||||||
value={scope.game}
|
value={scope.game}
|
||||||
onchange={(event) => void updateScope({ game: event.currentTarget.value, map: '' })}
|
onchange={(event) => void updateScope({ game: event.currentTarget.value, map: '' })}
|
||||||
><option value="">All games</option><option value="t5">Black Ops I</option><option
|
><option value="">All games</option><option value="t5">Black Ops I</option><option
|
||||||
value="t6">Black Ops II</option
|
value="t6">Black Ops II</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Map">
|
||||||
>Map<select
|
<Select
|
||||||
value={scope.map}
|
value={scope.map}
|
||||||
onchange={(event) => void updateScope({ map: event.currentTarget.value })}
|
onchange={(event) => void updateScope({ map: event.currentTarget.value })}
|
||||||
><option value="">All maps</option
|
><option value="">All maps</option
|
||||||
@@ -408,26 +410,26 @@
|
|||||||
>{/if}{#each filteredMaps as map (`${map.game}:${map.key}`)}<option
|
>{/if}{#each filteredMaps as map (`${map.game}:${map.key}`)}<option
|
||||||
value={map.key}
|
value={map.key}
|
||||||
>{map.name}{!scope.game ? ` · ${gameName(map.game)}` : ''}</option
|
>{map.name}{!scope.game ? ` · ${gameName(map.game)}` : ''}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Players">
|
||||||
>Players<select
|
<Select
|
||||||
value={scope.players ?? ''}
|
value={scope.players ?? ''}
|
||||||
onchange={(event) => void updateScope({ players: event.currentTarget.value })}
|
onchange={(event) => void updateScope({ players: event.currentTarget.value })}
|
||||||
><option value="">All squad sizes</option><option value="1">Solo</option><option
|
><option value="">All squad sizes</option><option value="1">Solo</option><option
|
||||||
value="2">Duo</option
|
value="2">Duo</option
|
||||||
><option value="3">Trio</option><option value="4">Four players</option></select
|
><option value="3">Trio</option><option value="4">Four players</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Rank by">
|
||||||
>Rank by<select
|
<Select
|
||||||
value={scope.metric}
|
value={scope.metric}
|
||||||
onchange={(event) => void updateScope({ metric: event.currentTarget.value })}
|
onchange={(event) => void updateScope({ metric: event.currentTarget.value })}
|
||||||
>{#each leaderboardMetrics as metric}<option value={metric.key}
|
>{#each leaderboardMetrics as metric}<option value={metric.key}
|
||||||
>{metric.label}</option
|
>{metric.label}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#snippet actions()}<Button onclick={() => (filterOpen = false)}>Done</Button>{/snippet}
|
{#snippet actions()}<Button onclick={() => (filterOpen = false)}>Done</Button>{/snippet}
|
||||||
@@ -608,15 +610,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
.scope-controls label {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.scope-controls select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.mobile-records {
|
.mobile-records {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
@@ -51,28 +53,28 @@
|
|||||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||||
<form onsubmit={login} aria-busy={busy}>
|
<form onsubmit={login} aria-busy={busy}>
|
||||||
<fieldset disabled={busy}>
|
<fieldset disabled={busy}>
|
||||||
<label for="username"
|
<Field forId="username" label="Username">
|
||||||
>Username<input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
maxlength="64"
|
maxlength={64}
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label for="password"
|
<Field forId="password" label="Password">
|
||||||
>Password<input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
maxlength="128"
|
maxlength={128}
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button>
|
<Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -102,20 +104,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.help,
|
.help,
|
||||||
.setup-link {
|
.setup-link {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
||||||
import BackLink from '$ui/components/BackLink.svelte';
|
import BackLink from '$ui/components/BackLink.svelte';
|
||||||
@@ -381,12 +384,9 @@
|
|||||||
<label class="toggle"
|
<label class="toggle"
|
||||||
><input type="checkbox" bind:checked={scheduleEnabled} /> Daily fleet restart</label
|
><input type="checkbox" bind:checked={scheduleEnabled} /> Daily fleet restart</label
|
||||||
>
|
>
|
||||||
<label
|
<Field
|
||||||
>Queue time ({$timeZone})<input
|
>{#snippet label()}Queue time ({$timeZone}){/snippet}
|
||||||
type="time"
|
<Input type="time" bind:value={scheduleTime} required /></Field
|
||||||
bind:value={scheduleTime}
|
|
||||||
required
|
|
||||||
/></label
|
|
||||||
>
|
>
|
||||||
<Button type="submit" disabled={busy}>Save schedule</Button>
|
<Button type="submit" disabled={busy}>Save schedule</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -490,30 +490,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label
|
<Field label="Capability">
|
||||||
>Capability<select bind:value={kind} onchange={() => saveContext()}
|
<Select bind:value={kind} onchange={() => saveContext()}
|
||||||
><option value="all">All capabilities</option><option value="health"
|
><option value="all">All capabilities</option><option value="health"
|
||||||
>Health recovery</option
|
>Health recovery</option
|
||||||
><option value="drift">Configuration drift</option><option
|
><option value="drift">Configuration drift</option><option
|
||||||
value="updates">Plutonium updates</option
|
value="updates">Plutonium updates</option
|
||||||
><option value="schedule">Scheduled restarts</option></select
|
><option value="schedule">Scheduled restarts</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Server">
|
||||||
>Server<input
|
<Input
|
||||||
bind:value={server}
|
bind:value={server}
|
||||||
oninput={() => saveContext()}
|
oninput={() => saveContext()}
|
||||||
placeholder="Filter servers"
|
placeholder="Filter servers"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
{#if view === 'activity'}<label
|
{#if view === 'activity'}<Field label="Status">
|
||||||
>Status<select bind:value={status} onchange={() => saveContext()}
|
<Select bind:value={status} onchange={() => saveContext()}
|
||||||
><option value="all">All activity</option><option value="active"
|
><option value="all">All activity</option><option value="active"
|
||||||
>Active</option
|
>Active</option
|
||||||
><option value="attention">Needs attention</option><option
|
><option value="attention">Needs attention</option><option
|
||||||
value="done">Finished</option
|
value="done">Finished</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>{/if}
|
>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if view === 'overview' && ['all', 'updates'].includes(kind)}
|
{#if view === 'overview' && ['all', 'updates'].includes(kind)}
|
||||||
@@ -808,8 +808,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']),
|
input:not([type='checkbox']) {
|
||||||
select {
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
|
|||||||
@@ -303,31 +303,37 @@
|
|||||||
{flash}<Button size="sm" onclick={load}>Retry</Button>
|
{flash}<Button size="sm" onclick={load}>Retry</Button>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{#if loading || plugins.length || flash}<div class="library-toolbar">
|
{#if loading || plugins.length || flash}<div class="library-toolbar">
|
||||||
<input
|
<Field label="Search plugins" hiddenLabel class="search"
|
||||||
class="search"
|
><Input
|
||||||
type="search"
|
type="search"
|
||||||
aria-label="Search plugins"
|
aria-label="Search plugins"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
placeholder="Search names or files…"
|
placeholder="Search names or files…"
|
||||||
/>
|
/></Field
|
||||||
<select aria-label="Filter plugins by game" bind:value={game}
|
|
||||||
><option value="">All games</option><option value="t5">Black Ops I</option><option
|
|
||||||
value="t6">Black Ops II</option
|
|
||||||
></select
|
|
||||||
>
|
>
|
||||||
<select aria-label="Filter plugins by status" bind:value={status}
|
<Field label="Filter plugins by game" hiddenLabel
|
||||||
><option value="">All statuses</option><option value="untested"
|
><Select aria-label="Filter plugins by game" bind:value={game}
|
||||||
>Needs testing</option
|
><option value="">All games</option><option value="t5">Black Ops I</option
|
||||||
><option value="testing">Beta active</option><option value="test-failed"
|
><option value="t6">Black Ops II</option></Select
|
||||||
>Test failed</option
|
></Field
|
||||||
><option value="tested">Test passed</option><option value="promoted"
|
|
||||||
>Promoted</option
|
|
||||||
><option value="empty">No versions</option></select
|
|
||||||
>
|
>
|
||||||
<select aria-label="Sort plugins" bind:value={sort}
|
<Field label="Filter plugins by status" hiddenLabel
|
||||||
><option value="name">Name A–Z</option><option value="recent"
|
><Select aria-label="Filter plugins by status" bind:value={status}
|
||||||
>Recently uploaded</option
|
><option value="">All statuses</option><option value="untested"
|
||||||
></select
|
>Needs testing</option
|
||||||
|
><option value="testing">Beta active</option><option value="test-failed"
|
||||||
|
>Test failed</option
|
||||||
|
><option value="tested">Test passed</option><option value="promoted"
|
||||||
|
>Promoted</option
|
||||||
|
><option value="empty">No versions</option></Select
|
||||||
|
></Field
|
||||||
|
>
|
||||||
|
<Field label="Sort plugins" hiddenLabel
|
||||||
|
><Select aria-label="Sort plugins" bind:value={sort}
|
||||||
|
><option value="name">Name A–Z</option><option value="recent"
|
||||||
|
>Recently uploaded</option
|
||||||
|
></Select
|
||||||
|
></Field
|
||||||
>
|
>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{#if game}<p class="filter-hint">
|
{#if game}<p class="filter-hint">
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import BackLink from '$ui/components/BackLink.svelte';
|
import BackLink from '$ui/components/BackLink.svelte';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
@@ -302,14 +305,14 @@
|
|||||||
panelId="deployment-panel"
|
panelId="deployment-panel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label class="search"
|
<Field class="search" label="Server">
|
||||||
>Server<input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
oninput={() => saveContext(true)}
|
oninput={() => saveContext(true)}
|
||||||
placeholder="Search server or lobby"
|
placeholder="Search server or lobby"
|
||||||
maxlength="100"
|
maxlength={100}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<div class="desktop-filters">
|
<div class="desktop-filters">
|
||||||
{@render filterFields()}
|
{@render filterFields()}
|
||||||
@@ -322,15 +325,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#snippet filterFields()}
|
{#snippet filterFields()}
|
||||||
<label
|
<Field label="Operation">
|
||||||
>Operation<select bind:value={action} onchange={() => saveContext(true)}
|
<Select bind:value={action} onchange={() => saveContext(true)}
|
||||||
><option value="all">All operations</option
|
><option value="all">All operations</option
|
||||||
>{#each provisioningActions as value}<option {value}>{actionLabels[value]}</option
|
>{#each provisioningActions as value}<option {value}>{actionLabels[value]}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Status">
|
||||||
>Status<select bind:value={historyState} onchange={() => saveContext(true)}
|
<Select bind:value={historyState} onchange={() => saveContext(true)}
|
||||||
><option value="all">All statuses</option><option value="pending"
|
><option value="all">All statuses</option><option value="pending"
|
||||||
>Waiting / queued</option
|
>Waiting / queued</option
|
||||||
><option value="running">In progress</option><option value="awaiting-health"
|
><option value="running">In progress</option><option value="awaiting-health"
|
||||||
@@ -341,16 +344,16 @@
|
|||||||
>Interrupted</option
|
>Interrupted</option
|
||||||
><option value="warning">Warnings</option><option value="cancelled"
|
><option value="warning">Warnings</option><option value="cancelled"
|
||||||
>Cancelled</option
|
>Cancelled</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field label="Source">
|
||||||
>Source<select bind:value={source} onchange={() => saveContext(true)}
|
<Select bind:value={source} onchange={() => saveContext(true)}
|
||||||
><option value="all">All sources</option
|
><option value="all">All sources</option
|
||||||
>{#each provisioningListSources as value}<option {value}
|
>{#each provisioningListSources as value}<option {value}
|
||||||
>{provisioningSourceLabels[value]}</option
|
>{provisioningSourceLabels[value]}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -636,27 +639,12 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
.toolbar :global(.search) {
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.search {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
input,
|
|
||||||
select {
|
|
||||||
padding: 10px 12px;
|
|
||||||
min-height: 40px;
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.queue-heading,
|
.queue-heading,
|
||||||
.queue-tools,
|
.queue-tools,
|
||||||
.pagination {
|
.pagination {
|
||||||
@@ -789,7 +777,7 @@
|
|||||||
table {
|
table {
|
||||||
min-width: 820px;
|
min-width: 820px;
|
||||||
}
|
}
|
||||||
.search {
|
.toolbar :global(.search) {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
th,
|
th,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -213,41 +216,39 @@
|
|||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<section class="library" aria-label="Script library">
|
<section class="library" aria-label="Script library">
|
||||||
{#if loading || scripts.length || error}<div class="library-toolbar">
|
{#if loading || scripts.length || error}<div class="library-toolbar">
|
||||||
<label class="search"
|
<Field class="search" hiddenLabel label="Search scripts">
|
||||||
><span class="sr-only">Search scripts</span><input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
placeholder="Search names, files, or maps…"
|
placeholder="Search names, files, or maps…"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field hiddenLabel label="Filter by game">
|
||||||
><span class="sr-only">Filter by game</span><select
|
<Select bind:value={game} onchange={() => (map = '')}
|
||||||
bind:value={game}
|
|
||||||
onchange={() => (map = '')}
|
|
||||||
><option value="">All games</option><option value="t5">Black Ops I</option
|
><option value="">All games</option><option value="t5">Black Ops I</option
|
||||||
><option value="t6">Black Ops II</option></select
|
><option value="t6">Black Ops II</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field hiddenLabel label="Filter by map">
|
||||||
><span class="sr-only">Filter by map</span><select bind:value={map}
|
<Select bind:value={map}
|
||||||
><option value="">All maps</option>{#each maps as m}<option value={m}
|
><option value="">All maps</option>{#each maps as m}<option value={m}
|
||||||
>{mapLabel(m)}</option
|
>{mapLabel(m)}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field hiddenLabel label="Filter by status">
|
||||||
><span class="sr-only">Filter by status</span><select bind:value={status}
|
<Select bind:value={status}
|
||||||
>{#each tabs as tab}<option value={tab.value}
|
>{#each tabs as tab}<option value={tab.value}
|
||||||
>{tab.value === 'all' ? 'All statuses' : tab.label}</option
|
>{tab.value === 'all' ? 'All statuses' : tab.label}</option
|
||||||
>{/each}<option value="empty">No versions</option></select
|
>{/each}<option value="empty">No versions</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<label
|
<Field hiddenLabel label="Sort scripts">
|
||||||
><span class="sr-only">Sort scripts</span><select bind:value={sort}
|
<Select bind:value={sort}
|
||||||
><option value="attention">Needs attention first</option><option
|
><option value="attention">Needs attention first</option><option
|
||||||
value="recent">Recently uploaded</option
|
value="recent">Recently uploaded</option
|
||||||
><option value="name">Name A–Z</option></select
|
><option value="name">Name A–Z</option></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-heading">
|
<div class="list-heading">
|
||||||
@@ -408,7 +409,6 @@
|
|||||||
}
|
}
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
select:focus-visible,
|
|
||||||
a:focus-visible {
|
a:focus-visible {
|
||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
@@ -173,27 +174,31 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
<div class="ui-toolbar">
|
<div class="ui-toolbar">
|
||||||
<input
|
<Field label="Search keys" hiddenLabel class="search"
|
||||||
type="search"
|
><Input
|
||||||
aria-label="Search keys"
|
type="search"
|
||||||
bind:value={search}
|
aria-label="Search keys"
|
||||||
oninput={(event) => {
|
bind:value={search}
|
||||||
search = event.currentTarget.value;
|
oninput={(event) => {
|
||||||
limit = 25;
|
search = event.currentTarget.value;
|
||||||
saveSearch();
|
limit = 25;
|
||||||
}}
|
saveSearch();
|
||||||
placeholder="Search labels or assigned servers…"
|
}}
|
||||||
/>
|
placeholder="Search labels or assigned servers…"
|
||||||
<select
|
/></Field
|
||||||
aria-label="Filter keys by game"
|
>
|
||||||
bind:value={gameFilter}
|
<Field label="Filter keys by game" hiddenLabel
|
||||||
onchange={() => {
|
><Select
|
||||||
limit = 25;
|
aria-label="Filter keys by game"
|
||||||
saveSearch();
|
bind:value={gameFilter}
|
||||||
}}
|
onchange={() => {
|
||||||
><option value="">All games</option><option value="t5">Black Ops I</option><option
|
limit = 25;
|
||||||
value="t6">Black Ops II</option
|
saveSearch();
|
||||||
></select
|
}}
|
||||||
|
><option value="">All games</option><option value="t5">Black Ops I</option><option
|
||||||
|
value="t6">Black Ops II</option
|
||||||
|
></Select
|
||||||
|
></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if error && !open}<div role="alert" class="ui-error">
|
{#if error && !open}<div role="alert" class="ui-error">
|
||||||
@@ -250,11 +255,11 @@
|
|||||||
<Dialog bind:open title="Add key" {busy}>
|
<Dialog bind:open title="Add key" {busy}>
|
||||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||||
<form onsubmit={save}>
|
<form onsubmit={save}>
|
||||||
<label
|
<Field label="Game">
|
||||||
>Game<select bind:value={game} disabled={busy}
|
<Select bind:value={game} disabled={busy}
|
||||||
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
|
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
|
||||||
></select
|
></Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
label="Label (optional)"
|
label="Label (optional)"
|
||||||
@@ -319,6 +324,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assignment {
|
.assignment {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@@ -331,22 +337,11 @@
|
|||||||
p {
|
p {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
form,
|
form {
|
||||||
label {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--text-base);
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
@@ -361,58 +364,54 @@
|
|||||||
<form onsubmit={create} aria-busy={busy}>
|
<form onsubmit={create} aria-busy={busy}>
|
||||||
<fieldset disabled={busy}>
|
<fieldset disabled={busy}>
|
||||||
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
||||||
<label for="new-username"
|
<Field forId="new-username" label="Username">
|
||||||
>Username<input
|
<Input
|
||||||
id="new-username"
|
id="new-username"
|
||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
minlength="3"
|
minlength={3}
|
||||||
maxlength="64"
|
maxlength={64}
|
||||||
pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'}
|
pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
aria-describedby="new-username-help"
|
aria-describedby="new-username-help"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<p class="hint" id="new-username-help">
|
<p class="hint" id="new-username-help">
|
||||||
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a
|
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a
|
||||||
letter or number.
|
letter or number.
|
||||||
</p>
|
</p>
|
||||||
<label for="new-name"
|
<Field forId="new-name" label="Display name">
|
||||||
>Display name<input
|
<Input
|
||||||
id="new-name"
|
id="new-name"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
required
|
required
|
||||||
maxlength="100"
|
maxlength={100}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={displayName}
|
bind:value={displayName}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label for="new-role"
|
<Field forId="new-role" label="Role">
|
||||||
>Role<select
|
<Select id="new-role" name="role" bind:value={role} aria-describedby="new-role-help"
|
||||||
id="new-role"
|
|
||||||
name="role"
|
|
||||||
bind:value={role}
|
|
||||||
aria-describedby="new-role-help"
|
|
||||||
>{#each roles as item}<option value={item.value}>{item.label}</option
|
>{#each roles as item}<option value={item.value}>{item.label}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<p class="hint" id="new-role-help">{roleDescription}</p>
|
<p class="hint" id="new-role-help">{roleDescription}</p>
|
||||||
<label for="temporary-password"
|
<Field forId="temporary-password" label="Temporary password">
|
||||||
>Temporary password<input
|
<Input
|
||||||
id="temporary-password"
|
id="temporary-password"
|
||||||
name="password"
|
name="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength={8}
|
||||||
maxlength="128"
|
maxlength={128}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
aria-describedby="temporary-help"
|
aria-describedby="temporary-help"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<p class="hint" id="temporary-help">
|
<p class="hint" id="temporary-help">
|
||||||
8–128 characters. Save it before adding the user; it will not be displayed again.
|
8–128 characters. Save it before adding the user; it will not be displayed again.
|
||||||
@@ -445,25 +444,25 @@
|
|||||||
<form onsubmit={save} aria-busy={busy}>
|
<form onsubmit={save} aria-busy={busy}>
|
||||||
<fieldset disabled={busy}>
|
<fieldset disabled={busy}>
|
||||||
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
||||||
<label for="edit-name"
|
<Field forId="edit-name" label="Display name">
|
||||||
>Display name<input
|
<Input
|
||||||
id="edit-name"
|
id="edit-name"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
required
|
required
|
||||||
maxlength="100"
|
maxlength={100}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={displayName}
|
bind:value={displayName}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label for="edit-role"
|
<Field forId="edit-role" label="Role">
|
||||||
>Role<select
|
<Select
|
||||||
id="edit-role"
|
id="edit-role"
|
||||||
name="role"
|
name="role"
|
||||||
bind:value={role}
|
bind:value={role}
|
||||||
aria-describedby="edit-role-help"
|
aria-describedby="edit-role-help"
|
||||||
>{#each roles as item}<option value={item.value}>{item.label}</option
|
>{#each roles as item}<option value={item.value}>{item.label}</option
|
||||||
>{/each}</select
|
>{/each}</Select
|
||||||
></label
|
></Field
|
||||||
>
|
>
|
||||||
<p class="hint" id="edit-role-help">{roleDescription}</p>
|
<p class="hint" id="edit-role-help">{roleDescription}</p>
|
||||||
<label class="checkbox"
|
<label class="checkbox"
|
||||||
@@ -476,17 +475,20 @@
|
|||||||
Use <a href="/account">My account</a> to change your own password. Changes to your
|
Use <a href="/account">My account</a> to change your own password. Changes to your
|
||||||
access can sign you out.
|
access can sign you out.
|
||||||
</p>
|
</p>
|
||||||
{:else}<label for="reset-password"
|
{:else}<Field forId="reset-password"
|
||||||
>New temporary password <span class="optional">(optional)</span><input
|
>{#snippet label()}New temporary password <span class="optional"
|
||||||
|
>(optional)</span
|
||||||
|
>{/snippet}
|
||||||
|
<Input
|
||||||
id="reset-password"
|
id="reset-password"
|
||||||
name="password"
|
name="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
minlength="8"
|
minlength={8}
|
||||||
maxlength="128"
|
maxlength={128}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
aria-describedby="reset-help"
|
aria-describedby="reset-help"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<p class="hint" id="reset-help">
|
<p class="hint" id="reset-help">
|
||||||
Leave blank to keep the current password. A reset requires the user to choose a
|
Leave blank to keep the current password. A reset requires the user to choose a
|
||||||
@@ -663,8 +665,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
input:not([type='checkbox']),
|
input:not([type='checkbox']) {
|
||||||
select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 11px;
|
padding: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
let token = $state('');
|
let token = $state('');
|
||||||
@@ -54,78 +56,78 @@
|
|||||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||||
<form onsubmit={setup} aria-busy={busy}>
|
<form onsubmit={setup} aria-busy={busy}>
|
||||||
<fieldset disabled={busy}>
|
<fieldset disabled={busy}>
|
||||||
<label for="setup-token"
|
<Field forId="setup-token" label="Setup token">
|
||||||
>Setup token<input
|
<Input
|
||||||
id="setup-token"
|
id="setup-token"
|
||||||
name="token"
|
name="token"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
minlength="32"
|
minlength={32}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
bind:value={token}
|
bind:value={token}
|
||||||
aria-describedby="token-help"
|
aria-describedby="token-help"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<p id="token-help" class="hint">
|
<p id="token-help" class="hint">
|
||||||
Provided through the controller’s <code>EC_SETUP_TOKEN</code> setting.
|
Provided through the controller’s <code>EC_SETUP_TOKEN</code> setting.
|
||||||
</p>
|
</p>
|
||||||
<label for="setup-username"
|
<Field forId="setup-username" label="Username">
|
||||||
>Username<input
|
<Input
|
||||||
id="setup-username"
|
id="setup-username"
|
||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
minlength="3"
|
minlength={3}
|
||||||
maxlength="64"
|
maxlength={64}
|
||||||
pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'}
|
pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
aria-describedby="username-help"
|
aria-describedby="username-help"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<p id="username-help" class="hint">
|
<p id="username-help" class="hint">
|
||||||
3–64 characters. Start with a letter or number; periods, underscores, and hyphens
|
3–64 characters. Start with a letter or number; periods, underscores, and hyphens
|
||||||
are allowed.
|
are allowed.
|
||||||
</p>
|
</p>
|
||||||
<label for="setup-name"
|
<Field forId="setup-name" label="Display name">
|
||||||
>Display name<input
|
<Input
|
||||||
id="setup-name"
|
id="setup-name"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
required
|
required
|
||||||
maxlength="100"
|
maxlength={100}
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
bind:value={displayName}
|
bind:value={displayName}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<label for="setup-password"
|
<Field forId="setup-password" label="Password">
|
||||||
>Password<input
|
<Input
|
||||||
id="setup-password"
|
id="setup-password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength={8}
|
||||||
maxlength="128"
|
maxlength={128}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
aria-describedby="password-help"
|
aria-describedby="password-help"
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<p id="password-help" class="hint">
|
<p id="password-help" class="hint">
|
||||||
Use 8–128 characters. A long, unique passphrase works well.
|
Use 8–128 characters. A long, unique passphrase works well.
|
||||||
</p>
|
</p>
|
||||||
<label for="setup-confirm"
|
<Field forId="setup-confirm" label="Confirm password">
|
||||||
>Confirm password<input
|
<Input
|
||||||
id="setup-confirm"
|
id="setup-confirm"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength={8}
|
||||||
maxlength="128"
|
maxlength={128}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
/></label
|
/></Field
|
||||||
>
|
>
|
||||||
<Button type="submit" variant="primary" disabled={busy} loading={busy}
|
<Button type="submit" variant="primary" disabled={busy} loading={busy}
|
||||||
>Create administrator</Button
|
>Create administrator</Button
|
||||||
@@ -153,20 +155,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-control);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.help,
|
.help,
|
||||||
.hint {
|
.hint {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|||||||
Vendored
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Field from '../../src/lib/ui/components/primitives/Field.svelte';
|
||||||
|
import Input from '../../src/lib/ui/components/primitives/Input.svelte';
|
||||||
|
import Select from '../../src/lib/ui/components/primitives/Select.svelte';
|
||||||
|
|
||||||
|
let count = $state<number | undefined>(3);
|
||||||
|
let order = $state(false);
|
||||||
|
let version = $state<number | null>(null);
|
||||||
|
let password = $state('');
|
||||||
|
let showPassword = $state(false);
|
||||||
|
let element = $state<HTMLInputElement>();
|
||||||
|
let selected = $state<HTMLSelectElement>();
|
||||||
|
let inputEvents = $state(0);
|
||||||
|
let changes = $state('');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={(event) => event.preventDefault()}>
|
||||||
|
<Field label="Lobbies" forId="count" hint="Choose one to five lobbies.">
|
||||||
|
<Input
|
||||||
|
id="count"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={1}
|
||||||
|
required
|
||||||
|
bind:value={count}
|
||||||
|
bind:element
|
||||||
|
oninput={(event) => (inputEvents = event.currentTarget.valueAsNumber)}
|
||||||
|
aria-describedby="count-extra"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p id="count-extra">A separate explanation.</p>
|
||||||
|
<Field label="Order" forId="order">
|
||||||
|
<Select
|
||||||
|
id="order"
|
||||||
|
bind:value={order}
|
||||||
|
bind:element={selected}
|
||||||
|
onchange={(event) => (changes = event.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value={true}>Newest</option>
|
||||||
|
<option value={false}>Oldest</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Version" forId="version">
|
||||||
|
<Select id="version" bind:value={version}>
|
||||||
|
<option value={null} disabled>Choose a version</option>
|
||||||
|
<optgroup label="Published"
|
||||||
|
><option value={2}>Version 2</option><option value={7}>Version 7</option></optgroup
|
||||||
|
>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Password" forId="password" error="Use at least eight characters.">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
bind:value={password}
|
||||||
|
minlength={8}
|
||||||
|
maxlength={128}
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field><Input ariaLabel="Standalone search" /></Field>
|
||||||
|
<button type="button" onclick={() => (showPassword = !showPassword)}>Show password</button>
|
||||||
|
<button type="button" onclick={() => element?.focus()}>Focus count</button>
|
||||||
|
<button type="button" onclick={() => selected?.focus()}>Focus order</button>
|
||||||
|
<output id="values">{JSON.stringify({ count, order, version, inputEvents, changes })}</output>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { expect, it } from 'vitest';
|
||||||
|
import { render } from 'svelte/server';
|
||||||
|
import FormControls from '../fixtures/FormControls.svelte';
|
||||||
|
import Select from '../../src/lib/ui/components/primitives/Select.svelte';
|
||||||
|
|
||||||
|
it('preserves native constraints and combines field help with explicit descriptions', () => {
|
||||||
|
const body = render(FormControls).body;
|
||||||
|
const count = body.match(/<input[^>]*id="count"[^>]*>/)?.[0] ?? '';
|
||||||
|
expect(count).toContain('type="number"');
|
||||||
|
expect(count).toContain('min="1"');
|
||||||
|
expect(count).toContain('max="5"');
|
||||||
|
expect(count).toContain('step="1"');
|
||||||
|
expect(count).toContain('required');
|
||||||
|
expect(count).toContain('value="3"');
|
||||||
|
const descriptions = count.match(/aria-describedby="([^"]+)"/)?.[1].split(' ') ?? [];
|
||||||
|
expect(descriptions).toHaveLength(2);
|
||||||
|
for (const id of descriptions) expect(body).toContain(`id="${id}"`);
|
||||||
|
expect(descriptions).toContain('count-extra');
|
||||||
|
const password = body.match(/<input[^>]*id="password"[^>]*>/)?.[0] ?? '';
|
||||||
|
expect(password).toContain('minlength="8"');
|
||||||
|
expect(password).toContain('maxlength="128"');
|
||||||
|
expect(password).toContain('autocomplete="new-password"');
|
||||||
|
expect(password).toContain('aria-invalid="true"');
|
||||||
|
const errorId = password.match(/aria-describedby="([^"]+)"/)?.[1];
|
||||||
|
expect(body).toContain(`id="${errorId}"`);
|
||||||
|
const standalone = body.match(/<input[^>]*aria-label="Standalone search"[^>]*>/)?.[0] ?? '';
|
||||||
|
expect(standalone).not.toContain('aria-labelledby');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders typed child options, groups and the shared chevron with the selected value', () => {
|
||||||
|
const body = render(FormControls).body;
|
||||||
|
expect(body).toMatch(/<option[^>]*value="false"[^>]*selected[^>]*>Oldest<\/option>/);
|
||||||
|
expect(body).toMatch(/<option[^>]*disabled[^>]*selected[^>]*>Choose a version<\/option>/);
|
||||||
|
expect(body).toContain('<optgroup label="Published">');
|
||||||
|
expect(body.match(/aria-hidden="true">▾/g)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the options API, placeholders and explicitly supplied accessibility attributes', () => {
|
||||||
|
const body = render(Select<number>, {
|
||||||
|
props: {
|
||||||
|
value: 2,
|
||||||
|
options: [
|
||||||
|
{ value: 1, label: 'One', disabled: true },
|
||||||
|
{ value: 2, label: 'Two' }
|
||||||
|
],
|
||||||
|
placeholder: 'Choose',
|
||||||
|
disabled: true,
|
||||||
|
name: 'quantity',
|
||||||
|
'aria-label': 'Quantity',
|
||||||
|
'aria-describedby': 'quantity-help',
|
||||||
|
'aria-invalid': true
|
||||||
|
}
|
||||||
|
}).body;
|
||||||
|
expect(body).toMatch(/<option value="" disabled[^>]*>Choose<\/option>/);
|
||||||
|
expect(body).toMatch(/<option[^>]*value="1"[^>]*disabled[^>]*>One<\/option>/);
|
||||||
|
expect(body).toMatch(/<option[^>]*value="2"[^>]*selected[^>]*>Two<\/option>/);
|
||||||
|
expect(body).toContain('aria-label="Quantity"');
|
||||||
|
expect(body).toContain('aria-describedby="quantity-help"');
|
||||||
|
expect(body).toContain('aria-invalid="true"');
|
||||||
|
expect(body).toMatch(/<select[^>]*disabled/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { checkFormControls } from '../../scripts/check-ui-styles.mjs';
|
||||||
|
|
||||||
|
const filename = 'src/routes/example/+page.svelte';
|
||||||
|
|
||||||
|
describe('shared form control guard', () => {
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
' type="text"',
|
||||||
|
' type="search"',
|
||||||
|
' type="password"',
|
||||||
|
' type="email"',
|
||||||
|
' type="url"',
|
||||||
|
' type="tel"',
|
||||||
|
' type="number"',
|
||||||
|
' type="time"',
|
||||||
|
' type={visible ? "text" : "password"}'
|
||||||
|
])('rejects raw input%s with a useful location', (attributes) => {
|
||||||
|
expect(checkFormControls(`<div>\n<input${attributes} />\n</div>`, filename)).toEqual([
|
||||||
|
`${filename}:2: use Field with Input instead of raw <input>`
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds selects nested in blocks without confusing event expressions for markup', () => {
|
||||||
|
const source =
|
||||||
|
'{#if ready}<select onchange={(event) => choose(event.currentTarget.value)}><option>One</option></select>{/if}';
|
||||||
|
expect(checkFormControls(source, filename)).toEqual([
|
||||||
|
`${filename}:1: use Field with Select instead of raw <select>`
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows native controls that do not use the text field presentation', () => {
|
||||||
|
const source = [
|
||||||
|
'checkbox',
|
||||||
|
'radio',
|
||||||
|
'file',
|
||||||
|
'hidden',
|
||||||
|
'range',
|
||||||
|
'color',
|
||||||
|
'button',
|
||||||
|
'submit',
|
||||||
|
'reset',
|
||||||
|
'image'
|
||||||
|
]
|
||||||
|
.map((type) => `<input type="${type}" />`)
|
||||||
|
.join('\n');
|
||||||
|
expect(checkFormControls(source, filename)).toEqual([]);
|
||||||
|
expect(checkFormControls('<input type={"checkbox"} />', filename)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores comments, script strings and shared components', () => {
|
||||||
|
const source =
|
||||||
|
'<script>const example = "<input>";</script><!-- <select></select> --><Field label="Search"><Input type="search" /></Field><Select options={[]} />';
|
||||||
|
expect(checkFormControls(source, filename)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows raw controls only in their implementing primitives', () => {
|
||||||
|
expect(
|
||||||
|
checkFormControls('<input />', 'src/lib/ui/components/primitives/Input.svelte')
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
checkFormControls('<select></select>', 'src/lib/ui/components/primitives/Select.svelte')
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
checkFormControls('<input />', 'src/lib/ui/components/primitives/Field.svelte')
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(checkFormControls('<input />', 'src/lib/ui/components/Input.svelte')).toHaveLength(
|
||||||
|
1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user