Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -11,17 +11,35 @@
|
||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||
|
||||
let { open = $bindable(false), sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '' }:
|
||||
{ open?: boolean; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string } = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
sourceDeploymentId = '',
|
||||
initialGame = 't5',
|
||||
initialMapSlug = ''
|
||||
}: {
|
||||
open?: boolean;
|
||||
sourceDeploymentId?: string;
|
||||
initialGame?: ZombieGame;
|
||||
initialMapSlug?: string;
|
||||
} = $props();
|
||||
let game = $state<ZombieGame>('t5');
|
||||
let mapSlug = $state('');
|
||||
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
||||
let gameSelect = $state<HTMLSelectElement>();
|
||||
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
||||
let hostReady = $state(false);
|
||||
let hostStatus = $state<NativeHostSelectionStatus>({ loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null });
|
||||
let hostStatus = $state<NativeHostSelectionStatus>({
|
||||
loading: true,
|
||||
error: '',
|
||||
hasHosts: false,
|
||||
filesReady: false,
|
||||
missingGames: [],
|
||||
uploadedAt: null
|
||||
});
|
||||
let view = $state<ServerLaunchState>({ preview: null, checking: false, error: '' });
|
||||
const controller = createServerLaunchController(state => { view = state; });
|
||||
const controller = createServerLaunchController((state) => {
|
||||
view = state;
|
||||
});
|
||||
let loaded = $state(false);
|
||||
let loadingError = $state('');
|
||||
let launchError = $state('');
|
||||
@@ -37,39 +55,89 @@
|
||||
const preview = $derived(view.preview);
|
||||
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
|
||||
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
|
||||
const checksBlocked = $derived(preview?.checks.filter(check => check.state === 'blocked') ?? []);
|
||||
const checksBlocked = $derived(
|
||||
preview?.checks.filter((check) => check.state === 'blocked') ?? []
|
||||
);
|
||||
const missingKey = $derived(!!preview?.name && !preview.hasSavedKey);
|
||||
const canLaunch = $derived(!launching && !!preview?.name && (unconfirmedLaunch ||
|
||||
(hostReady && !hostStatus.loading && !view.checking && !expired && preview.ready && preview.hasSavedKey)));
|
||||
const checking = $derived(!loaded && !loadingError || hostStatus.loading || view.checking);
|
||||
const footerStatus = $derived(launching ? 'Queueing server…' : unconfirmedLaunch ? 'Retry to confirm this launch.'
|
||||
: loadingError ? 'Could not load launch options.' : checking ? 'Checking availability…'
|
||||
: missingKey ? 'No game key available.' : canLaunch ? 'Ready to launch' : 'Launch unavailable');
|
||||
const canLaunch = $derived(
|
||||
!launching &&
|
||||
!!preview?.name &&
|
||||
(unconfirmedLaunch ||
|
||||
(hostReady &&
|
||||
!hostStatus.loading &&
|
||||
!view.checking &&
|
||||
!expired &&
|
||||
preview.ready &&
|
||||
preview.hasSavedKey))
|
||||
);
|
||||
const checking = $derived((!loaded && !loadingError) || hostStatus.loading || view.checking);
|
||||
const footerStatus = $derived(
|
||||
launching
|
||||
? 'Queueing server…'
|
||||
: unconfirmedLaunch
|
||||
? 'Retry to confirm this launch.'
|
||||
: loadingError
|
||||
? 'Could not load launch options.'
|
||||
: checking
|
||||
? 'Checking availability…'
|
||||
: missingKey
|
||||
? 'No game key available.'
|
||||
: canLaunch
|
||||
? 'Ready to launch'
|
||||
: 'Launch unavailable'
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (open && !wasOpen) { wasOpen = true; void initialize(); }
|
||||
else if (!open && wasOpen) {
|
||||
wasOpen = false; visit++; initialization?.abort(); controller.clear();
|
||||
if (open && !wasOpen) {
|
||||
wasOpen = true;
|
||||
void initialize();
|
||||
} else if (!open && wasOpen) {
|
||||
wasOpen = false;
|
||||
visit++;
|
||||
initialization?.abort();
|
||||
controller.clear();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
// Selection changes invalidate the old preview before another launch can use it.
|
||||
const input = open && loaded && hostReady && !hostStatus.loading ? {
|
||||
game, mapSlug, libraryId: selection.libraryIds[game]!, image: selection.image,
|
||||
bindAddress: selection.bindAddress || undefined, publicAddress: selection.publicAddress || undefined,
|
||||
publicPort: selection.publicPort
|
||||
} : null;
|
||||
const input =
|
||||
open && loaded && hostReady && !hostStatus.loading
|
||||
? {
|
||||
game,
|
||||
mapSlug,
|
||||
libraryId: selection.libraryIds[game]!,
|
||||
image: selection.image,
|
||||
bindAddress: selection.bindAddress || undefined,
|
||||
publicAddress: selection.publicAddress || undefined,
|
||||
publicPort: selection.publicPort
|
||||
}
|
||||
: null;
|
||||
if (!launching && !unconfirmedLaunch) controller.select(input);
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
const current = ++visit;
|
||||
initialization?.abort(); initialization = new AbortController();
|
||||
initialization?.abort();
|
||||
initialization = new AbortController();
|
||||
const signal = initialization.signal;
|
||||
controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
|
||||
game = initialGame; mapSlug = initialMapSlug;
|
||||
selection = { libraryIds: {}, image: '' }; hostReady = false;
|
||||
hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
|
||||
controller.clear();
|
||||
loaded = false;
|
||||
loadingError = '';
|
||||
launchError = '';
|
||||
launching = false;
|
||||
unconfirmedLaunch = false;
|
||||
game = initialGame;
|
||||
mapSlug = initialMapSlug;
|
||||
selection = { libraryIds: {}, image: '' };
|
||||
hostReady = false;
|
||||
hostStatus = {
|
||||
loading: true,
|
||||
error: '',
|
||||
hasHosts: false,
|
||||
filesReady: false,
|
||||
missingGames: [],
|
||||
uploadedAt: null
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/api/maps', { signal });
|
||||
if (!response.ok) throw new Error('Could not load maps. Try again.');
|
||||
@@ -77,75 +145,117 @@
|
||||
if (current !== visit) return;
|
||||
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
|
||||
if (sourceDeploymentId) {
|
||||
const sourceResponse = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`, { signal });
|
||||
const sourceResponse = await fetch(
|
||||
`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`,
|
||||
{ signal }
|
||||
);
|
||||
const source = await sourceResponse.json();
|
||||
if (current !== visit) return;
|
||||
if (!sourceResponse.ok || !source.recipe || !['t5', 't6'].includes(source.recipe.game)
|
||||
|| !catalogs[source.recipe.game as ZombieGame].some(map => map.slug === source.recipe.mapSlug)) {
|
||||
if (
|
||||
!sourceResponse.ok ||
|
||||
!source.recipe ||
|
||||
!['t5', 't6'].includes(source.recipe.game) ||
|
||||
!catalogs[source.recipe.game as ZombieGame].some(
|
||||
(map) => map.slug === source.recipe.mapSlug
|
||||
)
|
||||
) {
|
||||
throw new Error('The original launch map is unavailable.');
|
||||
}
|
||||
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
|
||||
game = source.recipe.game;
|
||||
mapSlug = source.recipe.mapSlug;
|
||||
}
|
||||
if (!catalogs[game].some(map => map.slug === mapSlug)) mapSlug = catalogs[game][0]?.slug ?? '';
|
||||
if (!catalogs[game].some((map) => map.slug === mapSlug))
|
||||
mapSlug = catalogs[game][0]?.slug ?? '';
|
||||
loaded = true;
|
||||
await tick();
|
||||
if (current === visit && open) gameSelect?.focus();
|
||||
} catch (error) {
|
||||
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load launch options.';
|
||||
if (current === visit && !signal.aborted)
|
||||
loadingError =
|
||||
error instanceof Error ? error.message : 'Could not load launch options.';
|
||||
}
|
||||
}
|
||||
|
||||
function refreshChecks() {
|
||||
if (!open || launching || unconfirmedLaunch) return;
|
||||
if (!loaded) { void initialize(); return; }
|
||||
if (!loaded) {
|
||||
void initialize();
|
||||
return;
|
||||
}
|
||||
launchError = '';
|
||||
controller.clear();
|
||||
refreshVersion++;
|
||||
}
|
||||
function selectionChanged() { launchError = ''; }
|
||||
function selectionChanged() {
|
||||
launchError = '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const refreshOnReturn = () => { if (document.visibilityState !== 'hidden') refreshChecks(); };
|
||||
const refreshOnReturn = () => {
|
||||
if (document.visibilityState !== 'hidden') refreshChecks();
|
||||
};
|
||||
window.addEventListener('focus', refreshOnReturn);
|
||||
document.addEventListener('visibilitychange', refreshOnReturn);
|
||||
const timer = setInterval(() => { now = Date.now(); }, 1000);
|
||||
const timer = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshOnReturn);
|
||||
document.removeEventListener('visibilitychange', refreshOnReturn);
|
||||
clearInterval(timer);
|
||||
};
|
||||
});
|
||||
onDestroy(() => { visit++; initialization?.abort(); submission?.abort(); controller.dispose(); });
|
||||
onDestroy(() => {
|
||||
visit++;
|
||||
initialization?.abort();
|
||||
submission?.abort();
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
async function launch() {
|
||||
if (!canLaunch || !preview) return;
|
||||
const current = visit;
|
||||
const reviewed = preview;
|
||||
controller.suspend(); launching = true; launchError = '';
|
||||
controller.suspend();
|
||||
launching = true;
|
||||
launchError = '';
|
||||
submission = new AbortController();
|
||||
try {
|
||||
const response = await fetch('/api/servers', { method: 'POST', signal: submission.signal,
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||||
game: reviewed.recipe.game, gameMode: 'zombie', mapSlug: reviewed.recipe.mapSlug,
|
||||
expectedName: reviewed.name, previewId: reviewed.id
|
||||
}) });
|
||||
const response = await fetch('/api/servers', {
|
||||
method: 'POST',
|
||||
signal: submission.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
game: reviewed.recipe.game,
|
||||
gameMode: 'zombie',
|
||||
mapSlug: reviewed.recipe.mapSlug,
|
||||
expectedName: reviewed.name,
|
||||
previewId: reviewed.id
|
||||
})
|
||||
});
|
||||
const body = await response.json();
|
||||
if (current !== visit) return;
|
||||
if (!response.ok || !body.ok) {
|
||||
unconfirmedLaunch = response.status >= 500;
|
||||
launchError = body.error ?? 'Could not queue this launch.';
|
||||
if (!unconfirmedLaunch) { controller.clear(); refreshVersion++; }
|
||||
if (!unconfirmedLaunch) {
|
||||
controller.clear();
|
||||
refreshVersion++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
open = false;
|
||||
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (current === visit) {
|
||||
// Retain the preview ID: retrying a possibly accepted launch must be idempotent.
|
||||
unconfirmedLaunch = true;
|
||||
launchError = 'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
|
||||
launchError =
|
||||
'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
|
||||
}
|
||||
} finally { if (current === visit) launching = false; }
|
||||
} finally {
|
||||
if (current === visit) launching = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -158,92 +268,326 @@
|
||||
<p class="muted" role="status">Loading maps…</p>
|
||||
{:else}
|
||||
<div class="fields">
|
||||
<label>Game<select bind:this={gameSelect} bind:value={game} disabled={launching || unconfirmedLaunch}
|
||||
onchange={event => { game = event.currentTarget.value as ZombieGame; mapSlug = catalogs[game][0]?.slug ?? ''; selectionChanged(); }}>
|
||||
<option value="t5">Black Ops I</option><option value="t6">Black Ops II</option>
|
||||
</select></label>
|
||||
<label>Map<select bind:value={mapSlug} disabled={launching || unconfirmedLaunch} onchange={selectionChanged}>
|
||||
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
|
||||
</select></label>
|
||||
<label
|
||||
>Game<select
|
||||
bind:this={gameSelect}
|
||||
bind:value={game}
|
||||
disabled={launching || unconfirmedLaunch}
|
||||
onchange={(event) => {
|
||||
game = event.currentTarget.value as ZombieGame;
|
||||
mapSlug = catalogs[game][0]?.slug ?? '';
|
||||
selectionChanged();
|
||||
}}
|
||||
>
|
||||
<option value="t5">Black Ops I</option><option value="t6"
|
||||
>Black Ops II</option
|
||||
>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>Map<select
|
||||
bind:value={mapSlug}
|
||||
disabled={launching || unconfirmedLaunch}
|
||||
onchange={selectionChanged}
|
||||
>
|
||||
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
</div>
|
||||
|
||||
<section class="summary" aria-label="Server summary" aria-busy={checking}>
|
||||
<strong>{preview?.name ? serverTitle(preview.name) : maps.find(map => map.slug === mapSlug)?.friendly ?? 'Choose a map'}</strong>
|
||||
<strong
|
||||
>{preview?.name
|
||||
? serverTitle(preview.name)
|
||||
: (maps.find((map) => map.slug === mapSlug)?.friendly ??
|
||||
'Choose a map')}</strong
|
||||
>
|
||||
<p class="muted">Uses the current production setup when the server starts.</p>
|
||||
<div class="readiness" aria-label="Launch readiness">
|
||||
{#if checking}<span role="status">Checking availability…</span>
|
||||
{:else}
|
||||
<span class:ready={hostStatus.filesReady}>{hostStatus.missingGames.length ? 'Game files missing' : hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'}</span>
|
||||
<span class:ready={hostStatus.filesReady}
|
||||
>{hostStatus.missingGames.length
|
||||
? 'Game files missing'
|
||||
: hostStatus.filesReady
|
||||
? 'Game files ready'
|
||||
: 'Game files preparing'}</span
|
||||
>
|
||||
{#if preview?.hasSavedKey}<span class="ready">Key available</span>{/if}
|
||||
<span>Automatic host & port</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if launchError}<div class="issue" role="alert"><p>{launchError}</p>{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener">View Provisioning ↗</a>{/if}</div>{/if}
|
||||
{#if missingKey}<div class="issue" role="status"><p>No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.</p><a href={`/server-keys?game=${game}`} target="_blank" rel="noopener">Manage keys ↗</a></div>{/if}
|
||||
{#if launchError}<div class="issue" role="alert">
|
||||
<p>{launchError}</p>
|
||||
{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener"
|
||||
>View Provisioning ↗</a
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
{#if missingKey}<div class="issue" role="status">
|
||||
<p>
|
||||
No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.
|
||||
</p>
|
||||
<a href={`/server-keys?game=${game}`} target="_blank" rel="noopener"
|
||||
>Manage keys ↗</a
|
||||
>
|
||||
</div>{/if}
|
||||
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
|
||||
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div class="issue" role="status"><p>Upload game files before launching.</p><a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a></div>
|
||||
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">Enter a valid runtime image in Advanced settings.</p>
|
||||
{:else if view.error && hostStatus.hasHosts}<p class="issue" role="alert">{view.error}</p>{/if}
|
||||
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div class="issue" role="status"><p>Add a host to run your server.</p><a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a></div>{/if}
|
||||
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div
|
||||
class="issue"
|
||||
role="status"
|
||||
>
|
||||
<p>Upload game files before launching.</p>
|
||||
<a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a>
|
||||
</div>
|
||||
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">
|
||||
Enter a valid runtime image in Advanced settings.
|
||||
</p>
|
||||
{:else if view.error && hostStatus.hasHosts}<p class="issue" role="alert">
|
||||
{view.error}
|
||||
</p>{/if}
|
||||
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div
|
||||
class="issue"
|
||||
role="status"
|
||||
>
|
||||
<p>Add a host to run your server.</p>
|
||||
<a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a>
|
||||
</div>{/if}
|
||||
{#each checksBlocked as check}<p class="issue" role="status">{check.detail}</p>{/each}
|
||||
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button class="text-button retry" disabled={launching} onclick={refreshChecks}>Retry checks</button>{/if}
|
||||
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button
|
||||
class="text-button retry"
|
||||
disabled={launching}
|
||||
onclick={refreshChecks}>Retry checks</button
|
||||
>{/if}
|
||||
|
||||
{#if open}<NativeHostSelection games={[game]} bind:selection bind:ready={hostReady} bind:status={hostStatus}
|
||||
compact network {refreshVersion} disabled={launching || unconfirmedLaunch} onchange={selectionChanged} />{/if}
|
||||
{#if open}<NativeHostSelection
|
||||
games={[game]}
|
||||
bind:selection
|
||||
bind:ready={hostReady}
|
||||
bind:status={hostStatus}
|
||||
compact
|
||||
network
|
||||
{refreshVersion}
|
||||
disabled={launching || unconfirmedLaunch}
|
||||
onchange={selectionChanged}
|
||||
/>{/if}
|
||||
|
||||
{#if preview}<details class="setup-details">
|
||||
<summary>Setup details</summary>
|
||||
<dl>
|
||||
<div><dt>Server ID</dt><dd><code>{preview.name ?? 'No lobby available'}</code></dd></div>
|
||||
<div><dt>Configuration</dt><dd>{preview.recipe.configSource}</dd></div>
|
||||
<div><dt>Container image</dt><dd><code>{preview.recipe.image}</code></dd></div>
|
||||
{#if hostStatus.uploadedAt}<div><dt>Game files uploaded</dt><dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd></div>{/if}
|
||||
</dl>
|
||||
<h3>{preview.recipe.files.length} managed files</h3>
|
||||
<ul class="files">{#each preview.recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span></div><code>{file.path}</code></li>{/each}</ul>
|
||||
</details>{/if}
|
||||
<summary>Setup details</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Server ID</dt>
|
||||
<dd><code>{preview.name ?? 'No lobby available'}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Configuration</dt>
|
||||
<dd>{preview.recipe.configSource}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Container image</dt>
|
||||
<dd><code>{preview.recipe.image}</code></dd>
|
||||
</div>
|
||||
{#if hostStatus.uploadedAt}<div>
|
||||
<dt>Game files uploaded</dt>
|
||||
<dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd>
|
||||
</div>{/if}
|
||||
</dl>
|
||||
<h3>{preview.recipe.files.length} managed files</h3>
|
||||
<ul class="files">
|
||||
{#each preview.recipe.files as file}<li>
|
||||
<div>
|
||||
<strong>{file.name}</strong><span
|
||||
>{file.version === null ? 'Generated' : `v${file.version}`} ·
|
||||
{file.kind}</span
|
||||
>
|
||||
</div>
|
||||
<code>{file.path}</code>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<div class="launch-actions">
|
||||
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
|
||||
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button></div>
|
||||
<div>
|
||||
<Button variant="ghost" disabled={launching} onclick={() => (open = false)}
|
||||
>Cancel</Button
|
||||
><Button
|
||||
variant="primary"
|
||||
loading={launching}
|
||||
disabled={!canLaunch}
|
||||
onclick={launch}
|
||||
>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.launch-content { display: grid; gap: 18px; }
|
||||
.fields { display: grid; grid-template-columns: 1fr 1.3fr; gap: 14px; }
|
||||
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
select { width: 100%; }
|
||||
.summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }
|
||||
.summary > strong { font-size: var(--text-md); line-height: 1.4; }
|
||||
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
|
||||
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.ready { color: var(--state-online); }
|
||||
.issue { display: grid; gap: 8px; padding: 12px; border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent); border-radius: var(--radius-md); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
|
||||
.text-button { border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
|
||||
.retry { justify-self: start; }
|
||||
.setup-details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
|
||||
summary { cursor: pointer; font-size: var(--text-sm); }
|
||||
dl { display: grid; gap: 14px; margin: 16px 0; }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: var(--text-sm); line-height: 1.5; }
|
||||
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
|
||||
h3 { font-size: var(--text-sm); margin: 18px 0 8px; }
|
||||
.files { list-style: none; padding: 0; margin: 0; }
|
||||
.files li { display: grid; gap: 5px; padding: 10px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
.files li > div { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
||||
.files strong { font-size: var(--text-sm); font-weight: 500; }
|
||||
.files span, .files code { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.launch-actions { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.launch-actions > p { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.launch-actions > p.ready { color: var(--state-online); }
|
||||
.launch-actions > div { display: flex; align-items: center; gap: 8px; }
|
||||
@media (max-width: 440px) { .fields { grid-template-columns: 1fr; } .launch-actions { align-items: stretch; flex-direction: column; } .launch-actions > div { justify-content: flex-end; } }
|
||||
.launch-content {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.3fr;
|
||||
gap: 14px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.summary > strong {
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.4;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.readiness {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ready {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.issue {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
a,
|
||||
.text-button {
|
||||
color: var(--accent-primary-hover);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.retry {
|
||||
justify-self: start;
|
||||
}
|
||||
.setup-details {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
dd {
|
||||
margin: 5px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-sm);
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
.files {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.files li {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.files li > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.files strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.files span,
|
||||
.files code {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.launch-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.launch-actions > p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.launch-actions > p.ready {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.launch-actions > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@media (max-width: 440px) {
|
||||
.fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.launch-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.launch-actions > div {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user