@@ -1,198 +1,249 @@
< script lang = "ts" >
import { timeFormat } from '$ui/stores/time-zone.js ';
import { onMount , tick } from 'svelte' ;
import { isDialogBackdrop } from '$ui/dialog.js' ;
import { onMount , onDestroy , tick } from 'svelte ';
import { goto } from '$app/navigation' ;
import Dialog from './primitives/Dialog.svelte' ;
import Button from './primitives/Button.svelte' ;
import LaunchRecipe from './LaunchRecipe.svelte' ;
import DeployCollectionDialog from './DeployCollectionDialog.svelte' ;
import NativeHostSelection from './NativeHostSelection.svelte' ;
import { createServerLaunchController , type ServerLaunchState } from '$ui/server-launch.js' ;
import { serverTitle } from '$ui/server-labels.js' ;
import { timeFormat } from '$ui/stores/time-zone.js' ;
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js' ;
import type { NativeDeploymentSelection } from '$lib/hosts.js' ;
import type { BlueprintSummary , LaunchPreview } from '$lib/deployment-plan.js' ;
import type { ZombieMapEntry , ZombieGame } from '$lib/map-aliases.js' ;
let { open = $bindable ( false ), initialBlueprintId = '' , sourceDeploymentId = '' , initialGame = 't5' , initialMapSlug = '' , onblueprint } :
{ open? : boolean ; initialBlueprintId? : string ; sourceDeploymentId? : string ; initialGame? : ZombieGame ; initialMapSlug? : string ; onblueprint ?: () => void } = $props ();
let dialog : HTMLDialogElement | undefined = $state ();
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 blueprints = $state < BlueprintSummary [] >([] );
let blueprintId = $state ( '' );
let collectionOpen = $state ( false );
let maps = $derived ( catalogs [ game ]);
let selectedBlueprint = $derived ( blueprints . find ( b => b . id === blueprintId ));
let preview = $state < LaunchPreview | null >( null );
let hostSelection = $state < NativeDeploymentSelection >({ libraryIds : {}, image : '' });
let gameSelect = $state < HTMLSelectElement >();
let selection = $state < NativeDeploymentSelection >({ libraryIds : {}, image : '' } );
let hostReady = $state ( false );
let busy = $state ( false );
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 ; });
let loaded = $state ( false );
let error = $state ( '' );
let blueprintName = $state ( '' );
let description = $state ( '' );
let savedName = $state ( '' );
let saving = $state ( false );
let wasOpen = false ;
let session = 0 ;
let lobbyNotice = $state ( '' );
let selectionNotice = $state ( '' );
let loadingError = $state ( '' );
let launchError = $state ( '' );
let launching = $state ( false );
let unconfirmedLaunch = $state ( false );
let refreshVersion = $state ( 0 );
let now = $state ( Date . now ());
let heading : HTMLHeadingElement ;
const expired = $derived ( !! preview && new Date ( preview . expiresAt ). getTime () <= now ) ;
onMount (() => { const timer = setInterval (() => { now = Date . now (); }, 10 _000 ); return () => clearInterval ( timer ); }) ;
let wasOpen = false ;
let visit = 0 ;
let initialization : AbortController | undefined ;
let submission : AbortController | undefined ;
const maps = $derived ( catalogs [ game ]);
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 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' );
$effect (() => {
if ( ! dialog ) return ;
if ( open && ! wasOpen ) { wasOpen = true ; dialog . showModal (); void initialize (); }
else if ( ! open && wasOpen ) { wasOpen = false ; session ++ ; dialog . close (); }
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 ;
if ( ! launching && ! unconfirmedLaunch ) controller . select ( input );
});
async function initialize() {
const current = ++ session ;
preview = null ; error = '' ; loaded = false ; busy = false ; lobbyNotice = '' ; selectionNotice = '' ;
blueprintName = '' ; description = '' ; savedName = '' ; saving = false ;
blueprintId = initialBlueprintId ;
game = initialGame ;
mapSlug = initialMapSlug ;
hostSelection = { libraryIds : {} , image : '' }; hostReady = false ;
const current = ++ visit ;
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 } ;
try {
const [ mapRes , blueprintRes ] = await Promise . all ([ fetch ( '/api/maps' ) , fetch ( '/api/blueprints' )] );
if ( ! mapRes . ok || ! blueprintRes . ok ) throw new Error ( 'Could not load maps and collections . Try again.' );
const [ mapBody , blueprintBody ] = await Promise . all ([ mapRes . json (), blueprintRes . json ()] );
if ( current !== session ) return ;
catalogs = { t5 : mapBody.t5Zombie , t6 : mapBody.t6Zombie };
blueprints = blueprintBody . blueprints ;
if ( ! maps . some ( m => m . slug === mapSlug )) mapSlug = maps [ 0 ] ? . slug ?? '' ;
if ( blueprintId && ! blueprints . some ( b => b . id === blueprintId )) throw new Error ( 'This collection is no longer available. Close this dialog and choose another starting point.' );
const response = await fetch ( '/api/maps' , { signal } );
if ( ! response . ok ) throw new Error ( 'Could not load maps. Try again.' );
const body = await response . json ( );
if ( current !== visit ) return ;
catalogs = { t5 : body.t5Zombie , t6 : body.t6Zombie };
if ( sourceDeploymentId ) {
const source = await fetch ( `/api/provisioning/ ${ encodeURIComponent ( sourceDeploymentId ) } ` ). then ( response => response . json () );
if ( current !== session ) return ;
if ( ! source . recipe ) throw new Error ( 'The original launch recipe is unavailable.' ) ;
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 )) {
throw new Error ( 'The original launch map is unavailable.' );
}
game = source . recipe . game ; mapSlug = source . recipe . mapSlug ;
}
if ( ! catalogs [ game ]. some ( map => map . slug === mapSlug )) mapSlug = catalogs [ game ][ 0 ] ? . slug ?? '' ;
loaded = true ;
} catch ( err ) { if ( current === session ) error = ( err as Error ). message ; }
}
function selectionChanged() {
if ( preview ) selectionNotice = 'The launch settings or game files changed. Review the launch again with the latest files.' ;
preview = null ; lobbyNotice = '' ;
}
async function review() {
if ( busy ) return ;
if ( ! sourceDeploymentId && selectedBlueprint ) {
open = false ; collectionOpen = true ; return ;
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 ( ! hostReady ) { error = 'Wait for a host to finish preparing the latest game files and runtime.' ; return ; }
const current = session ;
const previousName = preview ? . name ;
busy = true ; error = '' ; savedName = '' ; lobbyNotice = '' ; selectionNotice = '' ;
try {
const res = await fetch ( '/api/provisioning/preview' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' },
body : JSON.stringify ({ ...( sourceDeploymentId ? { deploymentId : sourceDeploymentId } : { game , mapSlug }),
libraryId : hostSelection.libraryIds [ game ], image : hostSelection.image ,
bindAddress : hostSelection.bindAddress || undefined , publicAddress : hostSelection.publicAddress || undefined , publicPort : hostSelection.publicPort }) });
const body = await res . json ();
if ( ! res . ok ) throw new Error ( body . error ?? 'Could not prepare launch preview.' );
if ( current === session ) {
if ( previousName && previousName !== body . name ) lobbyNotice = 'The available lobby changed. Review the updated server before launching.' ;
preview = body ; now = Date . now ();
await tick (); heading ? . focus ();
}
} catch ( err ) { if ( current === session ) error = ( err as Error ). message ; }
finally { if ( current === session ) busy = false ; }
}
function refreshChecks() {
if ( ! open || launching || unconfirmedLaunch ) return ;
if ( ! loaded ) { void initialize (); return ; }
launchError = '' ;
controller . clear ();
refreshVersion ++ ;
}
function selectionChanged() { launchError = '' ; }
onMount (() => {
const refreshOnReturn = () => { if ( document . visibilityState !== 'hidden' ) refreshChecks (); };
window . addEventListener ( 'focus' , refreshOnReturn );
document . addEventListener ( 'visibilitychange' , refreshOnReturn );
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 (); });
async function launch() {
if ( ! preview || ! preview . ready || ! preview . hasSavedKey || ! hostReady || expired || busy || saving ) return ;
busy = true ; error = '' ;
if ( ! canLaunch || ! preview ) return ;
const current = visit ;
const reviewed = preview ;
controller . suspend (); launching = true ; launchError = '' ;
submission = new AbortController ();
try {
const res = await fetch ( '/api/servers' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ({ game : preview.recipe.game , gameMode : 'zombie' , mapSlug : preview.recipe.mapSlug ,
expectedName : preview.name , previewId : preview.id }) });
const body = await res . json ();
if ( ! res . ok || ! body . ok ) throw new Error ( body . error ?? 'Could not queue this launch.' );
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 ++ ; }
return ;
}
open = false ;
await goto ( `/provisioning/ ${ encodeURIComponent ( body . deploymentId ) } ` );
} catch ( err ) { error = ( err as Error ). message ; }
finally { busy = false ; }
}
async function save() {
if ( ! preview || saving ) return ;
const current = session ;
saving = true ; error = '' ;
try {
const res = await fetch ( '/api/blueprints' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' },
body : JSON.stringify ({ name : blueprintName , description , previewId : preview.id }) });
const body = await res . json ();
if ( ! res . ok ) throw new Error ( body . error ?? 'Could not save collection.' );
if ( current === session ) { savedName = body . blueprint . name ; blueprints = [... blueprints , body . blueprint ]; onblueprint ? .(); }
} catch ( err ) { if ( current === session ) error = ( err as Error ). message ; }
finally { if ( current === session ) saving = false ; }
} catch ( error ) {
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.' ;
}
} finally { if ( current === visit ) launching = false ; }
}
</ script >
< dialog bind:this = { dialog } aria-label="Create server " onclick = { event => { if ( isDialogBackdrop ( event , dialog ) && ! busy && ! saving ) open = false ; }} oncancel= { event => { if ( busy || saving ) event . preventDefault (); }} onclose = {() => open = false } >
< div class = "composer " >
< header >< div >< h2 bind:this = { heading } tabindex="-1" > { preview ? 'Review server launch' : 'Create server' } </ h2 ></ div >< button class = "close" disabled = { busy || saving } onclick= {() => open = false } aria-label = "Close server creation dialog" > × </ button ></ header >
< ol class = "steps " aria-label = "Launch steps" >< li class:active = { ! preview } aria-current= { ! preview ? 'step' : undefined } > 01 < span > Choose a starting point </ span ></ li >< li class:active = { !! preview } aria-current= { preview ? 'step' : undefined } > 02 < span > Review & launch </ span ></ li ></ ol >
{ #if error } < div class = "error " role = "alert" > { error } </ div > { /if }
{ #if selectionNotice } < p class = "muted" role = "status" > { selectionNotice } </ p > { /if }
{ #if open && loaded && ( sourceDeploymentId || ! selectedBlueprint )} < NativeHostSelection games = {[ game ]} bind:selection= { hostSelection } bind:ready = { hostReady } disabled= { busy || saving } network onchange = { selectionChanged } / > { /if }
{ #if ! loaded }
< p class = "muted" > { error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…' } </ p >
{ #if error } < Button onclick = { initialize } > Try again </ Button > { /if }
{ :else if preview }
{ #if lobbyNotice } < p role = "status" class = "muted" > { lobbyNotice } </ p > { /if }
{ #if expired } < p class = "error" role = "status" > This preview expired. Refresh it to recheck capacity and key availability.</ p > { /if }
< section class = "recipe-panel" >< LaunchRecipe recipe = { preview . recipe } / > { #if preview . blueprintName } < p class = "muted" > From collection: { preview . blueprintName } </ p > { /if } </ section >
< section class = "preflight" >< div class = "section-title" >< h3 > Before you launch</ h3 >< button class = "text-button" disabled = { busy || saving } onclick= { review } > Refresh preview</ button ></ div >
< ul > { #each preview . checks as check } < li class:blocked = { check . state === 'blocked' } > <span class = "check" aria-label = { check . state } > { check . state === 'pass' ? '✓' : check . state === 'blocked' ? '!' : 'i' } </span >< div >< strong > { check . label } </ strong >< p > { check . detail } </ p ></ div ></ li > { /each } </ ul >
</ section >
< form onsubmit = { event => { event . preventDefault (); void launch (); }} >
< div class = "identity" >< span class = "eyebrow" > Your new server</ span >< strong > { preview . name ?? 'Waiting for an available lobby' } </ strong >< p > An available key from this game’ s pool is assigned when you launch.</ p ></ div >
{ #if preview . hasSavedKey } < p class = "key-ready" >< span aria-hidden = "true" > ✓</ span > Game key available</ p >
{ : else } < p class = "muted" role = "status" > No unused { preview . recipe . game . toUpperCase ()} keys are available. Add a key in < a href = { `/server-keys?game= ${ preview . recipe . game } ` } target="_blank" rel = "noopener" > Keys (opens in a new tab)</ a > , then Refresh preview to enable launch.</ p > { /if }
< div class = "launch-actions" >< Button variant = "ghost" disabled = { busy || saving } onclick= {() => { preview = null ; error = '' ; }} > Back</ Button >< Button type = "submit" variant = "primary" loading = { busy } disabled= { busy || saving || expired || ! hostReady || ! preview . ready || ! preview . hasSavedKey } > Launch server</ Button ></ div >
< p class = "caption" > Preview valid until { $timeFormat . minuteTime ( preview . expiresAt )} . Capacity and key availability are checked again at launch. The host and port are assigned automatically.</ p >
</ form >
< details class = "save" >< summary > Make this a reusable collection</ summary >< p class = "muted" > Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</ p >
{ #if savedName } < p class = "success" role = "status" > “{ savedName } ” is ready in your collection library.</ p > { : else }
< form onsubmit = { event => { event . preventDefault (); void save (); }} > <label > Collection name< input bind:value = { blueprintName } maxlength="80" required placeholder = "Friday night Kino" disabled = { saving || busy } / ></ label >< label > Description < span class = "muted" > (optional)</ span >< input bind:value = { description } maxlength="500" placeholder = "What makes this setup special?" disabled = { saving || busy } / ></ label >< Button type = "submit" disabled = { saving || busy || ! blueprintName . trim ()} loading= { saving } > Save collection</ Button ></ form >
{ /if }
</ details >
{ :else if sourceDeploymentId }
< p class = "muted" > Launch the latest promoted { game . toUpperCase ()} setup, with a fresh lobby and port.</ p >< Button disabled = { busy || ! hostReady } onclick= { review } > Review launch</ Button >
< Dialog bind:open title = "Create server" busy = { launching } >
< div class = "launch-content " >
{ #if loadingError }
< p class = "issue " role = "alert" > { loadingError } </ p >
< button class = "text-button " onclick = { refreshChecks } > Retry checks </ button >
{ :else if ! loaded }
< p class = "muted" role = "status" > Loading maps…</ p >
{ : else }
< form onsubmit = { event => { event . preventDefault (); void review (); }} >
< label > Start with < select bind:value = { blueprintId } disabled ={ busy } >< option value = "" > Current promoted setup </ option > { #each blueprints as blueprint } < option value = { blueprint . id } > { blueprint . name } · { blueprint . totalServers } server { blueprint . totalServers === 1 ? '' : 's' } </ option > { /each } </ select ></ label >
{ #if selectedBlueprint } < section class = "recipe-panel" >< h3 > { selectedBlueprint . totalServers } target server { selectedBlueprint . totalServers === 1 ? '' : 's' } </ h3 > { #each selectedBlueprint . entries as entry } < p > { entry . count } × { entry . mapName } · { entry . game . toUpperCase ()} </ p > { /each } < p class = "muted" > Review missing servers and game key availability together. Every new server uses the latest promoted scripts, plugins, and configuration. </ p > { #if selectedBlueprint . description } < p class = "muted" > { selectedBlueprint . description } </ p > { /if } </ section >
{ : else } < p class = "muted" > Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches. </ p >< div class = "fields" >< label > Game < select bind:value = { game } disabled= { busy } onchange = { event => mapSlug = catalogs [ event . currentTarget . value as ZombieGame ][ 0 ] ? . slug ?? '' } > <option value = "t5" > Black Ops I (T5) </ option >< option value = "t6" > Black Ops II (T6) </ option ></ select ></ label >< label > Map < select bind:value = { mapSlug } disabled= { busy } > { #each maps as map } < option value = { map . slug } > { map . friendly } </option > { /each } </ select ></ label ></ div > { /if }
< div class = "promise" >< strong > Know what you’ re launching. < /strong >< p > Review the current promoted setup, available lobby, and launch prerequisites before creating your server. </ p ></ div >
< div class = "launch-actions" >< Button variant = "ghost" disabled = { busy } onclick= {() => open = false } > Cancel </ Button >< Button type = "submit" variant = "primary" loading = { busy } disabled= { busy || ( ! blueprintId && ( ! mapSlug || ! hostReady ))} > Preview launch → </ Button ></ div >
</ form >
< 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 >
</ 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 >
< 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 >
{ #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 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 }
{ #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 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 }
{ /if }
</ div >
</ dialog >
< DeployCollectionDialog bind:open = { collectionOpen } { blueprintId } / >
{ # 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 launch' : 'Launch server' } </Button ></ div >
</ div >
{ /snippet }
</ Dialog >
< style >
. key-ready { color : var ( -- state - online ) ; font-weight : 600 ; }
dialog { background : var ( -- surface ); color : var ( -- text - primary ); border : 1 px solid var ( -- border - strong ); border-radius : var ( -- radius - lg ); padding : 0 ; width : min ( 700 px , calc ( 100 vw - 32 px )); max-height : calc ( 100 dvh - 40 px ) ; }
dialog :: backdrop { background : rgb ( 0 0 0 / .7 ); backdrop-filter : blur ( 5 px ); }
. composer { display : grid ; gap : 24 px ; padding : 30 px ; } header , . section-title { display : flex ; justify-content : space-between ; align-items : center ; gap : 16 px ; }
. eyebrow { font-size : var ( -- text - xs ) ; color : var ( -- text - secondary ) ; letter-spacing : .13 em ; text-transform : uppercase ; display : block ; margin-bottom : 8 px ; }
h2 { font-size : 25 px ; letter-spacing : -.03 em ; margin : 0 ; } h3 { font-size : 15 px ; margin : 0 ; }
. close { background : none ; color : var ( -- text - secondary ); border : 0 ; font-size : 27 px ; cursor : pointer ; }
. steps { list-style : none ; padding : 0 0 20 px ; margin : 0 ; display : flex ; gap : 24 px ; border-bottom : 1 px solid var ( -- border - subtle ); font-family : var ( -- font - mono ); font-size : 12 px ; color : var ( -- text - secondary ); }. steps span { font-family : var ( -- font - sans ); margin-left : 6 px ; }. steps . active { color : var ( -- accent - primary - hover ); }
form { display : grid ; gap : 16 px ; } label { display : grid ; gap : 8 px ; font-size : 13 px ; } input , select { width : 100 % ; box-sizing : border-box ; background : var ( -- surface - sunken ); color : var ( -- text - primary ); border : 1 px solid var ( -- border - control ); border-radius : var ( -- radius - md ); padding : 12 px ; font : inherit ; } input : focus , select : focus { outline : 2 px solid var ( -- accent - primary ); outline-offset : 2 px ; }
. fields { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 16 px ; } p { font-size : 13 px ; line-height : 1.6 ; margin : 0 ; } . muted , . caption { color : var ( -- text - secondary ); } . caption { font-size : var ( -- text - xs ); }
. recipe-panel , . promise { background : var ( -- surface - sunken ) ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); padding : 20 px ; display : grid ; gap : 12 px ; }. promise strong { font-size : 14 px ; } . promise p { color : var ( -- text - secondary ); }
. preflight { display : grid ; gap : 16 px ; } . preflight ul { list-style : none ; padding : 0 ; margin : 0 ; display : grid ; gap : 14 px ; } . preflight li { display : flex ; gap : 12 px ; align-items : flex-start ; }. preflight strong { font-size : 13 px ; font-weight : 550 ; }. preflight p { color : var ( -- text - secondary ); font-size : 12 px ; }. check { display : grid ; place-items : center ; flex : 0 0 22 px ; height : 22 px ; border : 1 px solid var ( -- border - strong ); border-radius : 50 % ; font-size : 12 px ; color : var ( -- accent - primary - hover ); }. blocked . check { color : var ( -- state - danger ); border-color : var ( -- state - danger ); }
. identity { padding-top : 20 px ; border-top : 1 px solid var ( -- border - subtle ); }. identity strong { font-family : var ( -- font - mono ) ; font-size : 15 px ; overflow-wrap : anywhere ; }. identity p { color : var ( -- text - secondary ) ; margin-top : 6 px ; }
. launch-actions { display : flex ; justify-content : flex-end ; gap : 12 px ; margin-top : 8 px ; }. error { padding : 12 px ; color : var ( -- state - danger ); border : 1 px solid var ( -- state - danger ); border-radius : var ( -- radius - md ); font-size : 13 px ; }. success { color : var ( -- accent - primary - hover ) ; }
a , . text-button { color : var ( -- accent - primary - hover ); }. text-button { background : none ; border : 0 ; cursor : pointer ; font : inherit ; font-size : 12 px ; }. save { border-top : 1 px solid var ( -- border - subtle ); padding-top : 20 px ; }. save summary { cursor : pointer ; font-size : 13 px ; }. save p , . save form { margin-top : 16 px ; }
@ media ( max-width : 540px ) { . composer { padding : 20 px ; } . fields { grid-template-columns : 1 fr ; } . steps { gap : 12 px ; font-size : var ( -- text - xs ); } h2 { font-size : 22 px ; } }
. launch-content { display : grid ; gap : 18 px ; }
. fields { display : grid ; grid-template-columns : 1 fr 1.3 fr ; gap : 14 px ; }
label { display : grid ; gap : 8 px ; color : var ( -- text - secondary ); font-size : var ( -- text - sm ); }
select { width : 100 % ; }
. summary { display : grid ; gap : 8 px ; padding : 16 px ; border : 1 px solid var ( -- border - subtle ) ; border-radius : var ( -- radius - md ) ; background : var ( -- surface - sunken ) ; }
. summary > strong { font-size : 17 px ; 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 : 8 px 14 px ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); }
. ready { color : var ( -- state - online ); }
. issue { display : grid ; gap : 8 px ; padding : 12 px ; border : 1 px 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 : 1 px solid var ( -- border - subtle ); padding-top : 14 px ; min-width : 0 ; }
summary { cursor : pointer ; font-size : var ( -- text - sm ); }
dl { display : grid ; gap : 14 px ; margin : 16 px 0 ; }
dt { color : var ( -- text - secondary ); font-size : var ( -- text - xs ); }
dd { margin : 5 px 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 : 18 px 0 8 px ; }
. files { list-style : none ; padding : 0 ; margin : 0 ; }
. files li { display : grid ; gap : 5 px ; padding : 10 px 0 ; border-bottom : 1 px solid var ( -- border - subtle ); }
. files li > div { display : flex ; justify-content : space-between ; flex-wrap : wrap ; gap : 8 px ; }
. 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 : 12 px ; }
. 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 : 8 px ; }
@ media ( max-width : 440px ) { . fields { grid-template-columns : 1 fr ; } . launch-actions { align-items : stretch ; flex-direction : column ; } . launch-actions > div { justify-content : flex-end ; } }
</ style >