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>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
<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>
|
||||
@@ -0,0 +1,605 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import { createGatewayClient } from "../../api/connect";
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
import {
|
||||
LobbyError,
|
||||
listMyApplications,
|
||||
listMyGames,
|
||||
listMyInvites,
|
||||
listPublicGames,
|
||||
redeemInvite,
|
||||
declineInvite,
|
||||
submitApplication,
|
||||
type ApplicationSummary,
|
||||
type GameSummary,
|
||||
type InviteSummary,
|
||||
} from "../../api/lobby";
|
||||
import { ByteBuffer } from "flatbuffers";
|
||||
import { AccountResponse } from "../../proto/galaxy/fbs/user";
|
||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import {
|
||||
SyntheticReportError,
|
||||
loadSyntheticReportFromJSON,
|
||||
} from "../../api/synthetic-report";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { Builder } from "flatbuffers";
|
||||
import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user";
|
||||
|
||||
let displayName: string | null = $state(null);
|
||||
let configError: string | null = $state(null);
|
||||
let listsLoading = $state(true);
|
||||
let lobbyError: string | null = $state(null);
|
||||
|
||||
let myGames: GameSummary[] = $state([]);
|
||||
let invitations: InviteSummary[] = $state([]);
|
||||
let applications: ApplicationSummary[] = $state([]);
|
||||
let publicGames: GameSummary[] = $state([]);
|
||||
|
||||
let openApplicationFor: string | null = $state(null);
|
||||
let raceNameInput = $state("");
|
||||
let raceNameError: string | null = $state(null);
|
||||
let submittingApplication = $state(false);
|
||||
|
||||
let inviteActionInFlight: string | null = $state(null);
|
||||
|
||||
let syntheticError: string | null = $state(null);
|
||||
|
||||
let client: GalaxyClient | null = null;
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await session.signOut("user");
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
if (client === null) return;
|
||||
listsLoading = true;
|
||||
lobbyError = null;
|
||||
try {
|
||||
const [games, invites, apps, publicPage] = await Promise.all([
|
||||
listMyGames(client),
|
||||
listMyInvites(client),
|
||||
listMyApplications(client),
|
||||
listPublicGames(client),
|
||||
]);
|
||||
myGames = games;
|
||||
invitations = invites.filter((invite) => invite.status === "pending");
|
||||
applications = apps;
|
||||
publicGames = publicPage.items;
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
} finally {
|
||||
listsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applicationStatusLabel(status: string): string {
|
||||
const key = `lobby.application.status.${status}` as TranslationKey;
|
||||
const translated = i18n.t(key);
|
||||
if (translated === key) {
|
||||
return i18n.t("lobby.application.status.unknown", { status });
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
function openApplicationForm(gameId: string): void {
|
||||
openApplicationFor = gameId;
|
||||
raceNameInput = "";
|
||||
raceNameError = null;
|
||||
}
|
||||
|
||||
function cancelApplicationForm(): void {
|
||||
openApplicationFor = null;
|
||||
raceNameInput = "";
|
||||
raceNameError = null;
|
||||
}
|
||||
|
||||
async function submitApplicationFor(gameId: string): Promise<void> {
|
||||
if (client === null) return;
|
||||
const trimmed = raceNameInput.trim();
|
||||
if (trimmed === "") {
|
||||
raceNameError = i18n.t("lobby.application.race_name_required");
|
||||
return;
|
||||
}
|
||||
submittingApplication = true;
|
||||
raceNameError = null;
|
||||
try {
|
||||
const result = await submitApplication(client, gameId, trimmed);
|
||||
applications = [result, ...applications];
|
||||
openApplicationFor = null;
|
||||
raceNameInput = "";
|
||||
} catch (err) {
|
||||
raceNameError = describeLobbyError(err);
|
||||
} finally {
|
||||
submittingApplication = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptInvite(invite: InviteSummary): Promise<void> {
|
||||
if (client === null) return;
|
||||
inviteActionInFlight = invite.inviteId;
|
||||
try {
|
||||
await redeemInvite(client, invite.gameId, invite.inviteId);
|
||||
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
|
||||
myGames = await listMyGames(client);
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
} finally {
|
||||
inviteActionInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectInvite(invite: InviteSummary): Promise<void> {
|
||||
if (client === null) return;
|
||||
inviteActionInFlight = invite.inviteId;
|
||||
try {
|
||||
await declineInvite(client, invite.gameId, invite.inviteId);
|
||||
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
} finally {
|
||||
inviteActionInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGreeting(c: GalaxyClient): Promise<void> {
|
||||
const builder = new Builder(32);
|
||||
GetMyAccountRequest.startGetMyAccountRequest(builder);
|
||||
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
|
||||
const result = await c.executeCommand("user.account.get", builder.asUint8Array());
|
||||
if (result.resultCode !== "ok") {
|
||||
return;
|
||||
}
|
||||
const response = AccountResponse.getRootAsAccountResponse(
|
||||
new ByteBuffer(result.payloadBytes),
|
||||
);
|
||||
const account = response.account();
|
||||
if (account === null) {
|
||||
return;
|
||||
}
|
||||
const display = account.displayName();
|
||||
const userName = account.userName();
|
||||
displayName = display && display.length > 0 ? display : userName;
|
||||
}
|
||||
|
||||
function gotoCreate(): void {
|
||||
appScreen.go("lobby-create");
|
||||
}
|
||||
|
||||
function gotoGame(gameId: string): void {
|
||||
// Enter a fresh game on the map view: reset the in-game view
|
||||
// state first so a stale snapshot from a previous game does not
|
||||
// leak into the new one, then switch the top-level screen.
|
||||
activeView.reset();
|
||||
appScreen.go("game", { gameId });
|
||||
}
|
||||
|
||||
async function onSyntheticFileChange(
|
||||
event: Event & { currentTarget: HTMLInputElement },
|
||||
): Promise<void> {
|
||||
// Capture the element synchronously: `event.currentTarget`
|
||||
// is nulled by the time any of the awaits below resolve, and
|
||||
// reaching for it from the `finally` block then throws
|
||||
// "null is not an object". The reset still has to happen so
|
||||
// re-selecting the same file fires `change` again.
|
||||
const input = event.currentTarget;
|
||||
syntheticError = null;
|
||||
const file = input.files?.[0];
|
||||
if (file === undefined) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json: unknown = JSON.parse(text);
|
||||
const { gameId } = loadSyntheticReportFromJSON(json);
|
||||
activeView.reset();
|
||||
appScreen.go("game", { gameId });
|
||||
} catch (err) {
|
||||
if (err instanceof SyntheticReportError) {
|
||||
syntheticError = err.message;
|
||||
} else if (err instanceof SyntaxError) {
|
||||
syntheticError = `invalid JSON: ${err.message}`;
|
||||
} else if (err instanceof Error) {
|
||||
syntheticError = err.message;
|
||||
} else {
|
||||
syntheticError = "failed to load synthetic report";
|
||||
}
|
||||
} finally {
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Statuses for which the game has a navigable in-game view.
|
||||
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
|
||||
// starting, start_failed) and terminal ones (cancelled) stay
|
||||
// non-clickable; entering them otherwise opens the game shell on a
|
||||
// game whose runtime state does not exist yet.
|
||||
function isPlayableStatus(status: string): boolean {
|
||||
return status === "running" || status === "paused" || status === "finished";
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (
|
||||
session.keypair === null ||
|
||||
session.deviceSessionId === null ||
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
||||
) {
|
||||
listsLoading = false;
|
||||
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
|
||||
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const keypair = session.keypair;
|
||||
try {
|
||||
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,
|
||||
});
|
||||
loadGreeting(client).catch(() => {});
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
listsLoading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
||||
<main id="main-content" tabindex="-1">
|
||||
<header>
|
||||
<h1>{i18n.t("lobby.title")}</h1>
|
||||
<p>
|
||||
{i18n.t("lobby.device_session_id_label")}:
|
||||
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
|
||||
</p>
|
||||
{#if displayName !== null}
|
||||
<p data-testid="account-greeting">
|
||||
{i18n.t("lobby.greeting", { name: displayName })}
|
||||
</p>
|
||||
{/if}
|
||||
<button onclick={logout} data-testid="lobby-logout">
|
||||
{i18n.t("lobby.logout")}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if configError !== null}
|
||||
<p role="alert" data-testid="account-error">{configError}</p>
|
||||
{:else if lobbyError !== null}
|
||||
<p role="alert" data-testid="lobby-error">{lobbyError}</p>
|
||||
{/if}
|
||||
|
||||
<section data-testid="lobby-create-section">
|
||||
<button onclick={gotoCreate} data-testid="lobby-create-button">
|
||||
{i18n.t("lobby.create_button")}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section data-testid="lobby-my-games-section">
|
||||
<h2>{i18n.t("lobby.section.my_games")}</h2>
|
||||
{#if listsLoading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if myGames.length === 0}
|
||||
<p data-testid="lobby-my-games-empty">{i18n.t("lobby.my_games.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each myGames as game (game.gameId)}
|
||||
<li>
|
||||
<button
|
||||
class="card"
|
||||
onclick={() => gotoGame(game.gameId)}
|
||||
disabled={!isPlayableStatus(game.status)}
|
||||
data-testid="lobby-my-game-card"
|
||||
>
|
||||
<strong>{game.gameName}</strong>
|
||||
<span class="meta">{game.status}</span>
|
||||
<span class="meta">{game.minPlayers}–{game.maxPlayers} players</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section data-testid="lobby-invitations-section">
|
||||
<h2>{i18n.t("lobby.section.invitations")}</h2>
|
||||
{#if listsLoading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if invitations.length === 0}
|
||||
<p data-testid="lobby-invitations-empty">{i18n.t("lobby.invitations.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each invitations as invite (invite.inviteId)}
|
||||
<li class="card">
|
||||
<strong>{invite.raceName}</strong>
|
||||
<span class="meta">{invite.gameId}</span>
|
||||
<div class="actions">
|
||||
<button
|
||||
onclick={() => acceptInvite(invite)}
|
||||
disabled={inviteActionInFlight === invite.inviteId}
|
||||
data-testid="lobby-invite-accept"
|
||||
>
|
||||
{i18n.t("lobby.invitation.accept")}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => rejectInvite(invite)}
|
||||
disabled={inviteActionInFlight === invite.inviteId}
|
||||
data-testid="lobby-invite-decline"
|
||||
>
|
||||
{i18n.t("lobby.invitation.decline")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section data-testid="lobby-applications-section">
|
||||
<h2>{i18n.t("lobby.section.applications")}</h2>
|
||||
{#if listsLoading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if applications.length === 0}
|
||||
<p data-testid="lobby-applications-empty">{i18n.t("lobby.applications.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each applications as app (app.applicationId)}
|
||||
<li class="card" data-testid="lobby-application-card">
|
||||
<strong>{app.raceName}</strong>
|
||||
<span class="meta">{app.gameId}</span>
|
||||
<span class="status" data-status={app.status}>
|
||||
{applicationStatusLabel(app.status)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true"}
|
||||
<!--
|
||||
Synthetic-report loader. Dev-only affordance for visual testing
|
||||
against rich game states without playing many turns. The JSON
|
||||
is produced offline by the Go CLI in
|
||||
`tools/local-dev/legacy-report/`; see
|
||||
`ui/docs/testing.md#synthetic-reports` for the workflow. Gated
|
||||
on `VITE_GALAXY_DEV_AFFORDANCES` (set in `.env.development` and
|
||||
mirrored by `dev-deploy.yaml`) rather than `import.meta.env.DEV`
|
||||
so the long-lived dev environment can also surface it from a
|
||||
production-mode bundle. The prod build path leaves the flag
|
||||
unset, so the section is stripped from prod chunks.
|
||||
-->
|
||||
<section data-testid="lobby-synthetic-section">
|
||||
<h2>Synthetic test reports (DEV)</h2>
|
||||
<p class="meta">
|
||||
Load a JSON file produced by
|
||||
<code>legacy-report-to-json</code> to open the map view
|
||||
against a synthetic snapshot. Orders compose locally but
|
||||
never reach the server.
|
||||
</p>
|
||||
<label class="synthetic-loader">
|
||||
Load JSON…
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onchange={onSyntheticFileChange}
|
||||
data-testid="lobby-synthetic-file"
|
||||
/>
|
||||
</label>
|
||||
{#if syntheticError !== null}
|
||||
<p role="alert" data-testid="lobby-synthetic-error">
|
||||
{syntheticError}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section data-testid="lobby-public-games-section">
|
||||
<h2>{i18n.t("lobby.section.public_games")}</h2>
|
||||
{#if listsLoading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if publicGames.length === 0}
|
||||
<p data-testid="lobby-public-games-empty">{i18n.t("lobby.public_games.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each publicGames as game (game.gameId)}
|
||||
<li class="card">
|
||||
<strong>{game.gameName}</strong>
|
||||
<span class="meta">{game.status}</span>
|
||||
<span class="meta">{game.minPlayers}–{game.maxPlayers} players</span>
|
||||
{#if openApplicationFor === game.gameId}
|
||||
<form
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitApplicationFor(game.gameId);
|
||||
}}
|
||||
data-testid="lobby-application-form"
|
||||
>
|
||||
<label>
|
||||
{i18n.t("lobby.application.race_name_label")}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={raceNameInput}
|
||||
data-testid="lobby-application-race-name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
{#if raceNameError !== null}
|
||||
<p role="alert" data-testid="lobby-application-error">
|
||||
{raceNameError}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submittingApplication}
|
||||
data-testid="lobby-application-submit"
|
||||
>
|
||||
{i18n.t("lobby.application.submit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelApplicationForm}
|
||||
data-testid="lobby-application-cancel"
|
||||
>
|
||||
{i18n.t("lobby.application.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => openApplicationForm(game.gameId)}
|
||||
data-testid="lobby-public-game-apply"
|
||||
>
|
||||
{i18n.t("lobby.application.submit_for", { name: game.gameName })}
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 1.5rem 1rem;
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface-raised);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button.card:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-text-faint);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
li.card {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
align-self: flex-start;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-raised);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
font-size: 1rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.synthetic-loader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px dashed var(--color-text-muted);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface-raised);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.synthetic-loader input[type="file"] {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<script lang="ts">
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
import {
|
||||
AuthError,
|
||||
confirmEmailCode,
|
||||
sendEmailCode,
|
||||
} from "../../api/auth";
|
||||
import { GATEWAY_BASE_URL } from "$lib/env";
|
||||
import { i18n, SUPPORTED_LOCALES } from "$lib/i18n/index.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
|
||||
type Step = "email" | "code";
|
||||
|
||||
let step: Step = $state("email");
|
||||
let email = $state("");
|
||||
let code = $state("");
|
||||
let challengeId: string | null = $state(null);
|
||||
let pending = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Safari ignores `autocomplete="off"` on type=email / login-shaped
|
||||
// fields and pops the Keychain suggester regardless. The classic
|
||||
// workaround is to render the input as `readonly` initially —
|
||||
// Safari does not autofill readonly fields — and drop the
|
||||
// attribute on the first user focus so typing still works. Once
|
||||
// dropped, the flag stays false for the rest of the page life.
|
||||
let emailReadonly = $state(true);
|
||||
let codeReadonly = $state(true);
|
||||
|
||||
function describe(err: unknown): string {
|
||||
if (err instanceof AuthError) {
|
||||
return err.message;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return "request failed";
|
||||
}
|
||||
|
||||
async function submitEmail(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const trimmed = email.trim();
|
||||
if (trimmed.length === 0) {
|
||||
error = i18n.t("login.email_required");
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
||||
locale: i18n.locale,
|
||||
});
|
||||
challengeId = result.challengeId;
|
||||
code = "";
|
||||
step = "code";
|
||||
} catch (err) {
|
||||
error = describe(err);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
const trimmedCode = code.trim();
|
||||
if (trimmedCode.length === 0) {
|
||||
error = i18n.t("login.code_required");
|
||||
return;
|
||||
}
|
||||
if (challengeId === null) {
|
||||
error = i18n.t("login.challenge_expired");
|
||||
step = "email";
|
||||
return;
|
||||
}
|
||||
if (session.keypair === null) {
|
||||
error = i18n.t("login.device_key_not_ready");
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await confirmEmailCode(GATEWAY_BASE_URL, {
|
||||
challengeId,
|
||||
code: trimmedCode,
|
||||
publicKey: session.keypair.publicKey,
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
await session.signIn(result.deviceSessionId);
|
||||
appScreen.go("lobby");
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError && err.code === "invalid_request") {
|
||||
challengeId = null;
|
||||
code = "";
|
||||
step = "email";
|
||||
error = i18n.t("login.code_expired_or_used");
|
||||
} else {
|
||||
error = describe(err);
|
||||
}
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resend(): Promise<void> {
|
||||
if (pending) return;
|
||||
const trimmed = email.trim();
|
||||
if (trimmed.length === 0) {
|
||||
step = "email";
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
|
||||
locale: i18n.locale,
|
||||
});
|
||||
challengeId = result.challengeId;
|
||||
code = "";
|
||||
} catch (err) {
|
||||
error = describe(err);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeEmail(): void {
|
||||
challengeId = null;
|
||||
code = "";
|
||||
error = null;
|
||||
step = "email";
|
||||
}
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
||||
<main id="main-content" tabindex="-1">
|
||||
<header>
|
||||
<h1>{i18n.t("login.title")}</h1>
|
||||
<div class="language-picker">
|
||||
<svg
|
||||
class="globe"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12 3a13 13 0 0 1 0 18M12 3a13 13 0 0 0 0 18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
<label class="sr-only" for="login-language-select">
|
||||
{i18n.t("common.language")}
|
||||
</label>
|
||||
<select
|
||||
id="login-language-select"
|
||||
data-testid="login-language-select"
|
||||
bind:value={i18n.locale}
|
||||
>
|
||||
{#each SUPPORTED_LOCALES as locale (locale.code)}
|
||||
<option value={locale.code}>{locale.nativeName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if step === "email"}
|
||||
<form
|
||||
onsubmit={submitEmail}
|
||||
aria-busy={pending}
|
||||
autocomplete="off"
|
||||
>
|
||||
<label>
|
||||
{i18n.t("login.email_label")}
|
||||
<input
|
||||
type="email"
|
||||
name="galaxy-login-email"
|
||||
autocomplete="new-password"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
readonly={emailReadonly}
|
||||
onfocus={() => (emailReadonly = false)}
|
||||
bind:value={email}
|
||||
disabled={pending}
|
||||
required
|
||||
data-testid="login-email-input"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
data-testid="login-email-submit"
|
||||
>
|
||||
{pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form
|
||||
onsubmit={submitCode}
|
||||
aria-busy={pending}
|
||||
autocomplete="off"
|
||||
>
|
||||
<p data-testid="login-code-target">
|
||||
{i18n.t("login.code_sent_to", { email })}
|
||||
</p>
|
||||
<label>
|
||||
{i18n.t("login.code_label")}
|
||||
<input
|
||||
type="text"
|
||||
name="galaxy-login-code"
|
||||
inputmode="numeric"
|
||||
autocomplete="new-password"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
readonly={codeReadonly}
|
||||
onfocus={() => (codeReadonly = false)}
|
||||
bind:value={code}
|
||||
disabled={pending}
|
||||
required
|
||||
data-testid="login-code-input"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pending} data-testid="login-code-submit">
|
||||
{pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
|
||||
</button>
|
||||
<div class="secondary">
|
||||
<button
|
||||
type="button"
|
||||
onclick={resend}
|
||||
disabled={pending}
|
||||
data-testid="login-resend"
|
||||
>
|
||||
{i18n.t("login.send_new_code")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={changeEmail}
|
||||
disabled={pending}
|
||||
data-testid="login-change-email"
|
||||
>
|
||||
{i18n.t("login.change_email")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if error !== null}
|
||||
<p role="alert" data-testid="login-error">{error}</p>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 2rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.language-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.globe {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.language-picker select {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
form > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.secondary button {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
[role="alert"] {
|
||||
margin-top: 1rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user