Files
galaxy-game/ui/frontend/src/lib/screens/lobby-create-screen.svelte
T
Ilia Denisov b6770d394c feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state
Collapse the game UI to one route (`/`): a screen dispatcher renders
login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of
URL routes. Move screen components to lib/screens & lib/game; the game shell
reads the game id from `appScreen.gameId` and re-inits per-game stores via an
$effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites
to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now
state-based. WIP: browser-history (Back→lobby), restore-validation, the
return-to-lobby button, push deep-links, and the test migration are follow-ups
on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:04:04 +02:00

294 lines
7.3 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import { LobbyError, createGame } from "../../api/lobby";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
const DEFAULT_MIN_PLAYERS = 2;
const DEFAULT_MAX_PLAYERS = 8;
const DEFAULT_START_GAP_HOURS = 24;
const DEFAULT_START_GAP_PLAYERS = 2;
const DEFAULT_TARGET_ENGINE_VERSION = "v1";
let gameName = $state("");
let description = $state("");
let turnSchedule = $state("0 0 * * *");
let enrollmentEndsAt = $state("");
let minPlayers = $state(DEFAULT_MIN_PLAYERS);
let maxPlayers = $state(DEFAULT_MAX_PLAYERS);
let startGapHours = $state(DEFAULT_START_GAP_HOURS);
let startGapPlayers = $state(DEFAULT_START_GAP_PLAYERS);
let targetEngineVersion = $state(DEFAULT_TARGET_ENGINE_VERSION);
let formError: string | null = $state(null);
let configError: string | null = $state(null);
let submitting = $state(false);
let client: GalaxyClient | null = null;
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
function describeLobbyError(err: unknown): string {
if (err instanceof LobbyError) {
const key = `lobby.error.${err.code}` as TranslationKey;
const translated = i18n.t(key);
if (translated !== key) {
return translated;
}
return i18n.t("lobby.error.unknown", { message: err.message });
}
return err instanceof Error ? err.message : "request failed";
}
function cancel(): void {
appScreen.go("lobby");
}
async function submit(): Promise<void> {
formError = null;
const trimmedName = gameName.trim();
const trimmedSchedule = turnSchedule.trim();
const trimmedEnrollment = enrollmentEndsAt.trim();
if (trimmedName === "") {
formError = i18n.t("lobby.create.game_name_required");
return;
}
if (trimmedSchedule === "") {
formError = i18n.t("lobby.create.turn_schedule_required");
return;
}
if (trimmedEnrollment === "") {
formError = i18n.t("lobby.create.enrollment_ends_at_required");
return;
}
const enrollmentDate = new Date(trimmedEnrollment);
if (Number.isNaN(enrollmentDate.getTime())) {
formError = i18n.t("lobby.create.enrollment_ends_at_required");
return;
}
if (client === null) {
formError = configError ?? "client not ready";
return;
}
submitting = true;
try {
await createGame(client, {
gameName: trimmedName,
description: description.trim(),
minPlayers,
maxPlayers,
startGapHours,
startGapPlayers,
enrollmentEndsAt: enrollmentDate,
turnSchedule: trimmedSchedule,
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
});
appScreen.go("lobby");
} catch (err) {
formError = describeLobbyError(err);
} finally {
submitting = false;
}
}
onMount(async () => {
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
}
return;
}
const keypair = session.keypair;
const core = await loadCore();
client = new GalaxyClient({
core,
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
});
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<main id="main-content" tabindex="-1">
<h1>{i18n.t("lobby.create.title")}</h1>
{#if configError !== null}
<p role="alert" data-testid="lobby-create-config-error">{configError}</p>
{/if}
<form
onsubmit={(event) => {
event.preventDefault();
submit();
}}
data-testid="lobby-create-form"
>
<label>
{i18n.t("lobby.create.game_name_label")}
<input
type="text"
bind:value={gameName}
data-testid="lobby-create-game-name"
autocomplete="off"
/>
</label>
<label>
{i18n.t("lobby.create.description_label")}
<textarea
bind:value={description}
data-testid="lobby-create-description"
rows="3"
></textarea>
</label>
<label>
{i18n.t("lobby.create.turn_schedule_label")}
<input
type="text"
bind:value={turnSchedule}
data-testid="lobby-create-turn-schedule"
autocomplete="off"
/>
<small>{i18n.t("lobby.create.turn_schedule_hint")}</small>
</label>
<label>
{i18n.t("lobby.create.enrollment_ends_at_label")}
<input
type="datetime-local"
bind:value={enrollmentEndsAt}
data-testid="lobby-create-enrollment-ends-at"
/>
</label>
<details data-testid="lobby-create-advanced">
<summary>{i18n.t("lobby.create.advanced")}</summary>
<label>
{i18n.t("lobby.create.min_players_label")}
<input
type="number"
min="1"
bind:value={minPlayers}
data-testid="lobby-create-min-players"
/>
</label>
<label>
{i18n.t("lobby.create.max_players_label")}
<input
type="number"
min="1"
bind:value={maxPlayers}
data-testid="lobby-create-max-players"
/>
</label>
<label>
{i18n.t("lobby.create.start_gap_hours_label")}
<input
type="number"
min="0"
bind:value={startGapHours}
data-testid="lobby-create-start-gap-hours"
/>
</label>
<label>
{i18n.t("lobby.create.start_gap_players_label")}
<input
type="number"
min="0"
bind:value={startGapPlayers}
data-testid="lobby-create-start-gap-players"
/>
</label>
<label>
{i18n.t("lobby.create.target_engine_version_label")}
<input
type="text"
bind:value={targetEngineVersion}
data-testid="lobby-create-target-engine-version"
autocomplete="off"
/>
</label>
</details>
{#if formError !== null}
<p role="alert" data-testid="lobby-create-error">{formError}</p>
{/if}
<div class="actions">
<button type="submit" disabled={submitting} data-testid="lobby-create-submit">
{submitting ? i18n.t("lobby.create.submitting") : i18n.t("lobby.create.submit")}
</button>
<button type="button" onclick={cancel} data-testid="lobby-create-cancel">
{i18n.t("lobby.create.cancel")}
</button>
</div>
</form>
</main>
<style>
main {
padding: 1.5rem 1rem;
max-width: 32rem;
margin: 0 auto;
font-family: system-ui, sans-serif;
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
input[type="text"],
input[type="number"],
input[type="datetime-local"],
textarea {
font-size: 1rem;
padding: 0.4rem 0.5rem;
}
details {
border: 1px solid var(--color-border);
border-radius: 0.4rem;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
details > label {
margin-top: 0.5rem;
}
details summary {
cursor: pointer;
font-weight: 600;
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
small {
color: var(--color-text-muted);
}
</style>