feat(ui): lobby site-style sidebar + profile screen (#47)
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome: page-list sidebar (Overview/Profile) and a top "Player-xxxx" identity strip mirroring the project site's monospace look. - Strip the legacy `lobby.title`, device-session-id `<code>`, and `lobby.greeting` paragraph; the identity strip both names the user and opens the profile editor. - Add a top-level `profile` AppScreen with a three-field form (`display_name`, `preferred_language`, `time_zone`) backed by a new `src/api/account.ts` wrapper around `user.account.get`, `user.profile.update`, and `user.settings.update`. Saving switches the active i18n locale in-place when the new preferred language is one the UI ships translations for. - Update e2e fixture + auth-flow / lobby-flow specs to use the new `lobby-account-name` testid and wait for the loaded identity before releasing pending `SubscribeEvents` (webkit revocation race). New `profile-screen.spec.ts` covers navigation, edit-save, and cancel. - Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new layout. Closes #47
This commit is contained in:
+52
-11
@@ -2,16 +2,36 @@
|
||||
|
||||
The lobby is the first authenticated view; the user lands here after
|
||||
the email-code login completes (see
|
||||
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the
|
||||
sections, the application / invite lifecycle the user sees, and
|
||||
the defaults baked into the create-game form.
|
||||
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared
|
||||
shell, the Overview sections, the profile sub-screen, and the
|
||||
defaults baked into the create-game form.
|
||||
|
||||
## Sections
|
||||
## Shell
|
||||
|
||||
The lobby renders one column of sections, top to bottom, with the
|
||||
common content max-width capped at `32rem` (same convention as the
|
||||
login page). Cards inside each section take the full available
|
||||
width.
|
||||
Lobby and profile share a single chrome implemented in
|
||||
`lib/screens/lobby-shell.svelte`. The chrome mirrors the project
|
||||
site's VitePress layout: a left page-list sidebar (Overview /
|
||||
Profile), a top identity strip on the right, and the page content in
|
||||
the right-hand column. The shell uses `var(--font-mono)` so the
|
||||
post-login pages adopt the "nerdy" type stack that the public site
|
||||
already uses.
|
||||
|
||||
The identity strip renders the caller's `display_name` (falling back
|
||||
to the immutable `user_name` handle, then to a loading placeholder
|
||||
while `user.account.get` resolves) as a `data-testid="lobby-account-name"`
|
||||
button. Clicking it switches the top-level screen to `profile`
|
||||
(`appScreen.go("profile")`); the e2e suites use that testid as their
|
||||
lobby-loaded signal. The logout button sits next to it
|
||||
(`session.signOut("user")`).
|
||||
|
||||
The sidebar always renders both pages; clicking the active page is a
|
||||
no-op. The shell collapses to a horizontal scrolling strip below
|
||||
640px.
|
||||
|
||||
## Overview sections
|
||||
|
||||
The Overview page renders one column of sections, top to bottom.
|
||||
Cards inside each section take the full available width.
|
||||
|
||||
| Section | Empty state | Source | Action |
|
||||
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
|
||||
@@ -21,9 +41,30 @@ width.
|
||||
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) |
|
||||
| `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) |
|
||||
|
||||
The header preserves the device-session-id `<code>` block (kept as
|
||||
a debug affordance) plus a greeting if the gateway returns a
|
||||
`display_name` for the caller.
|
||||
## Profile sub-screen
|
||||
|
||||
`lib/screens/profile-screen.svelte` is a top-level `AppScreen` (peer of
|
||||
`lobby` and `lobby-create`). The browser Back stack treats it the
|
||||
same as the create screen — pushing a fresh history entry on entry,
|
||||
falling back to lobby on Back/Forward (see
|
||||
[`navigation.md`](navigation.md)).
|
||||
|
||||
On mount it issues `user.account.get` through `src/api/account.ts`
|
||||
and renders an identity read-out (immutable `user_name`, `email`)
|
||||
plus a three-field form:
|
||||
|
||||
| Field | Endpoint | Notes |
|
||||
| --------------------- | --------------------- | -------------------------------------------------------------- |
|
||||
| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). |
|
||||
| `preferred_language` | `user.settings.update`| `<select>` over `SUPPORTED_LOCALES`; if the stored value is unsupported the option is preserved verbatim so a round-trip save does not silently switch it. |
|
||||
| `time_zone` | `user.settings.update`| Free-text IANA name. Placeholder shows the browser's current zone; backend validates with `time.LoadLocation`. |
|
||||
|
||||
Save fires `user.profile.update` and/or `user.settings.update`
|
||||
conditionally on which fields actually changed, then returns to the
|
||||
lobby (`appScreen.go("lobby")`). When the saved
|
||||
`preferred_language` is one the UI also ships translations for, the
|
||||
active i18n locale switches in-place so the rest of the session
|
||||
matches the new preference.
|
||||
|
||||
`GameSummary` carries a `current_turn` field that the lobby UI does
|
||||
not display directly — the in-game shell reads it from the same
|
||||
|
||||
+11
-7
@@ -18,9 +18,9 @@ for the whole session. The only other routes are the dev/test-only
|
||||
rune singletons in `src/lib/app-nav.svelte.ts`:
|
||||
|
||||
- **`appScreen`** — the top-level screen
|
||||
(`login` / `lobby` / `lobby-create` / `game`) plus the active
|
||||
`gameId`. It replaces the old `goto`-based redirects and the `[id]`
|
||||
route param.
|
||||
(`login` / `lobby` / `lobby-create` / `profile` / `game`) plus the
|
||||
active `gameId`. It replaces the old `goto`-based redirects and the
|
||||
`[id]` route param.
|
||||
- **`activeView`** — the in-game view (`map` / `table` / `report` /
|
||||
`battle` / `mail` / `designer-science`) plus the sub-parameters the
|
||||
old route segments carried (`tableEntity`, `battleId`, `turn`,
|
||||
@@ -31,8 +31,11 @@ render: it gates on `session.status` (anonymous → login, authenticated
|
||||
→ the `appScreen.screen`), and for the authenticated tree mounts the
|
||||
matching screen component from `src/lib/screens/`
|
||||
(`login-screen.svelte`, `lobby-screen.svelte`,
|
||||
`lobby-create-screen.svelte`) or, for `screen === "game"`, the in-game
|
||||
shell `src/lib/game/game-shell.svelte`. The game shell in turn renders
|
||||
`lobby-create-screen.svelte`, `profile-screen.svelte`) or, for
|
||||
`screen === "game"`, the in-game shell
|
||||
`src/lib/game/game-shell.svelte`. Lobby and profile share a
|
||||
post-login chrome (sidebar + identity strip) implemented in
|
||||
`lib/screens/lobby-shell.svelte`; see [`lobby.md`](lobby.md). The game shell in turn renders
|
||||
the active view from `activeView` (see below). Navigation is
|
||||
`appScreen.go(screen, { gameId })` and `activeView.select(view,
|
||||
params)` — never `goto`.
|
||||
@@ -73,8 +76,9 @@ Browser **Back/Forward move between screens**, not views, and they do
|
||||
so without ever changing the URL. The shell layers screen history on
|
||||
top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)`
|
||||
calls `pushState("", { screen, gameId })` for the overlay screens
|
||||
(`game`, `lobby-create`) and `replaceState(...)` for `lobby` / `login`,
|
||||
so browser **Back from a game returns to the lobby** beneath it. On
|
||||
(`game`, `lobby-create`, `profile`) and `replaceState(...)` for
|
||||
`lobby` / `login`, so browser **Back from a game (or profile) returns
|
||||
to the lobby** beneath it. On
|
||||
the first authenticated render the dispatcher stamps the restored
|
||||
overlay on top of the load entry, then mirrors `page.state` back into
|
||||
the store on every popstate through `appScreen.syncFromHistory(...)`.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Typed wrappers around `GalaxyClient.executeCommand` for the user-
|
||||
// account command catalog. Each wrapper builds a FlatBuffers request
|
||||
// payload via the generated TS bindings, calls `executeCommand`, then
|
||||
// decodes the `AccountResponse` reply. Errors with a non-`ok`
|
||||
// `result_code` surface as a thrown `AccountError` carrying the
|
||||
// canonical backend code (`invalid_request`, `subject_not_found`,
|
||||
// `forbidden`, `conflict`, `internal_error`).
|
||||
|
||||
import { Builder, ByteBuffer } from "flatbuffers";
|
||||
|
||||
import type { GalaxyClient } from "./galaxy-client";
|
||||
import {
|
||||
AccountResponse,
|
||||
AccountView,
|
||||
ErrorResponse as FbsErrorResponse,
|
||||
GetMyAccountRequest,
|
||||
UpdateMyProfileRequest,
|
||||
UpdateMySettingsRequest,
|
||||
} from "../proto/galaxy/fbs/user";
|
||||
|
||||
const RESULT_CODE_OK = "ok";
|
||||
|
||||
export class AccountError extends Error {
|
||||
readonly code: string;
|
||||
readonly resultCode: string;
|
||||
|
||||
constructor(resultCode: string, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "AccountError";
|
||||
this.resultCode = resultCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
userId: string;
|
||||
email: string;
|
||||
userName: string;
|
||||
displayName: string;
|
||||
preferredLanguage: string;
|
||||
timeZone: string;
|
||||
declaredCountry: string;
|
||||
}
|
||||
|
||||
export async function getMyAccount(client: GalaxyClient): Promise<Account> {
|
||||
const builder = new Builder(32);
|
||||
GetMyAccountRequest.startGetMyAccountRequest(builder);
|
||||
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
|
||||
const payload = await execute(client, "user.account.get", builder.asUint8Array());
|
||||
return decodeAccountResponse(payload);
|
||||
}
|
||||
|
||||
export async function updateMyProfile(
|
||||
client: GalaxyClient,
|
||||
displayName: string,
|
||||
): Promise<Account> {
|
||||
const builder = new Builder(128);
|
||||
const displayNameOff = builder.createString(displayName);
|
||||
UpdateMyProfileRequest.startUpdateMyProfileRequest(builder);
|
||||
UpdateMyProfileRequest.addDisplayName(builder, displayNameOff);
|
||||
builder.finish(UpdateMyProfileRequest.endUpdateMyProfileRequest(builder));
|
||||
const payload = await execute(client, "user.profile.update", builder.asUint8Array());
|
||||
return decodeAccountResponse(payload);
|
||||
}
|
||||
|
||||
export async function updateMySettings(
|
||||
client: GalaxyClient,
|
||||
preferredLanguage: string,
|
||||
timeZone: string,
|
||||
): Promise<Account> {
|
||||
const builder = new Builder(128);
|
||||
const preferredLanguageOff = builder.createString(preferredLanguage);
|
||||
const timeZoneOff = builder.createString(timeZone);
|
||||
UpdateMySettingsRequest.startUpdateMySettingsRequest(builder);
|
||||
UpdateMySettingsRequest.addPreferredLanguage(builder, preferredLanguageOff);
|
||||
UpdateMySettingsRequest.addTimeZone(builder, timeZoneOff);
|
||||
builder.finish(UpdateMySettingsRequest.endUpdateMySettingsRequest(builder));
|
||||
const payload = await execute(client, "user.settings.update", builder.asUint8Array());
|
||||
return decodeAccountResponse(payload);
|
||||
}
|
||||
|
||||
async function execute(
|
||||
client: GalaxyClient,
|
||||
messageType: string,
|
||||
payloadBytes: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const result = await client.executeCommand(messageType, payloadBytes);
|
||||
if (result.resultCode !== RESULT_CODE_OK) {
|
||||
throw decodeAccountError(result.resultCode, result.payloadBytes);
|
||||
}
|
||||
return result.payloadBytes;
|
||||
}
|
||||
|
||||
function decodeAccountError(resultCode: string, payload: Uint8Array): AccountError {
|
||||
let code = resultCode;
|
||||
let message = resultCode;
|
||||
try {
|
||||
const errorResponse = FbsErrorResponse.getRootAsErrorResponse(new ByteBuffer(payload));
|
||||
const body = errorResponse.error();
|
||||
if (body) {
|
||||
code = body.code() ?? resultCode;
|
||||
message = body.message() ?? resultCode;
|
||||
}
|
||||
} catch (_err) {
|
||||
// fall through with the raw result code
|
||||
}
|
||||
return new AccountError(resultCode, code, message);
|
||||
}
|
||||
|
||||
function decodeAccountResponse(payload: Uint8Array): Account {
|
||||
if (payload.length === 0) {
|
||||
throw new AccountError("internal_error", "internal_error", "empty account response");
|
||||
}
|
||||
const response = AccountResponse.getRootAsAccountResponse(new ByteBuffer(payload));
|
||||
const view = response.account();
|
||||
if (view === null) {
|
||||
throw new AccountError("internal_error", "internal_error", "account missing in response");
|
||||
}
|
||||
return decodeAccountView(view);
|
||||
}
|
||||
|
||||
function decodeAccountView(view: AccountView): Account {
|
||||
return {
|
||||
userId: view.userId() ?? "",
|
||||
email: view.email() ?? "",
|
||||
userName: view.userName() ?? "",
|
||||
displayName: view.displayName() ?? "",
|
||||
preferredLanguage: view.preferredLanguage() ?? "",
|
||||
timeZone: view.timeZone() ?? "",
|
||||
declaredCountry: view.declaredCountry() ?? "",
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ declare global {
|
||||
// (and active game) live in `page.state` so browser Back/Forward
|
||||
// move between screens while the address bar stays at /game/.
|
||||
interface PageState {
|
||||
screen?: "login" | "lobby" | "lobby-create" | "game";
|
||||
screen?: "login" | "lobby" | "lobby-create" | "profile" | "game";
|
||||
gameId?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
import { pushState, replaceState } from "$app/navigation";
|
||||
|
||||
export type AppScreen = "login" | "lobby" | "lobby-create" | "game";
|
||||
export type AppScreen = "login" | "lobby" | "lobby-create" | "profile" | "game";
|
||||
|
||||
export type GameView =
|
||||
| "map"
|
||||
@@ -51,6 +51,7 @@ const APP_SCREENS: readonly AppScreen[] = [
|
||||
"login",
|
||||
"lobby",
|
||||
"lobby-create",
|
||||
"profile",
|
||||
"game",
|
||||
];
|
||||
const GAME_VIEWS: readonly GameView[] = [
|
||||
@@ -183,7 +184,11 @@ class AppScreenStore {
|
||||
#syncHistory(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const state: App.PageState = { screen: this.#screen, gameId: this.#gameId };
|
||||
if (this.#screen === "game" || this.#screen === "lobby-create") {
|
||||
if (
|
||||
this.#screen === "game" ||
|
||||
this.#screen === "lobby-create" ||
|
||||
this.#screen === "profile"
|
||||
) {
|
||||
pushState("", state);
|
||||
} else {
|
||||
replaceState("", state);
|
||||
|
||||
@@ -50,11 +50,11 @@ const en = {
|
||||
"login.device_key_not_ready":
|
||||
"device key is not ready, please reload the page",
|
||||
|
||||
"lobby.title": "you are logged in",
|
||||
"lobby.device_session_id_label": "device session id",
|
||||
"lobby.greeting": "hello, {name}!",
|
||||
"lobby.account_loading": "loading account…",
|
||||
"lobby.logout": "logout",
|
||||
"lobby.nav.aria_label": "lobby pages",
|
||||
"lobby.nav.overview": "Overview",
|
||||
"lobby.nav.profile": "Profile",
|
||||
"lobby.section.my_games": "my games",
|
||||
"lobby.section.invitations": "pending invitations",
|
||||
"lobby.section.applications": "my applications",
|
||||
@@ -103,6 +103,21 @@ const en = {
|
||||
"lobby.error.internal_error": "internal server error",
|
||||
"lobby.error.unknown": "{message}",
|
||||
|
||||
"profile.title": "Profile",
|
||||
"profile.loading": "loading account…",
|
||||
"profile.field.user_name": "username",
|
||||
"profile.field.email": "email",
|
||||
"profile.field.display_name": "display name",
|
||||
"profile.field.preferred_language": "preferred language",
|
||||
"profile.field.time_zone": "time zone",
|
||||
"profile.hint.display_name": "shown wherever Galaxy needs a friendlier name than the username handle. Leave empty to fall back to the username.",
|
||||
"profile.hint.time_zone": "IANA time-zone name (e.g. Europe/Moscow, America/New_York). The placeholder shows your browser's current zone.",
|
||||
"profile.save": "save",
|
||||
"profile.saving": "saving…",
|
||||
"profile.cancel": "cancel",
|
||||
"profile.error.language_required": "language must not be empty",
|
||||
"profile.error.time_zone_required": "time zone must not be empty",
|
||||
|
||||
"game.shell.unknown": "?",
|
||||
"game.shell.connection.online": "online",
|
||||
"game.shell.connection.reconnecting": "reconnecting…",
|
||||
|
||||
@@ -51,11 +51,11 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"login.device_key_not_ready":
|
||||
"ключ устройства ещё не готов, перезагрузите страницу",
|
||||
|
||||
"lobby.title": "вы вошли в систему",
|
||||
"lobby.device_session_id_label": "идентификатор сессии устройства",
|
||||
"lobby.greeting": "здравствуйте, {name}!",
|
||||
"lobby.account_loading": "загрузка профиля…",
|
||||
"lobby.logout": "выйти",
|
||||
"lobby.nav.aria_label": "разделы лобби",
|
||||
"lobby.nav.overview": "Обзор",
|
||||
"lobby.nav.profile": "Профиль",
|
||||
"lobby.section.my_games": "мои игры",
|
||||
"lobby.section.invitations": "ожидающие приглашения",
|
||||
"lobby.section.applications": "мои заявки",
|
||||
@@ -104,6 +104,21 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"lobby.error.internal_error": "внутренняя ошибка сервера",
|
||||
"lobby.error.unknown": "{message}",
|
||||
|
||||
"profile.title": "Профиль",
|
||||
"profile.loading": "загрузка профиля…",
|
||||
"profile.field.user_name": "идентификатор",
|
||||
"profile.field.email": "электронная почта",
|
||||
"profile.field.display_name": "отображаемое имя",
|
||||
"profile.field.preferred_language": "язык интерфейса",
|
||||
"profile.field.time_zone": "часовой пояс",
|
||||
"profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.",
|
||||
"profile.hint.time_zone": "имя часового пояса IANA (например, Europe/Moscow, America/New_York). В подсказке — текущий пояс твоего браузера.",
|
||||
"profile.save": "сохранить",
|
||||
"profile.saving": "сохраняем…",
|
||||
"profile.cancel": "отмена",
|
||||
"profile.error.language_required": "язык не должен быть пустым",
|
||||
"profile.error.time_zone_required": "часовой пояс не должен быть пустым",
|
||||
|
||||
"game.shell.unknown": "?",
|
||||
"game.shell.connection.online": "онлайн",
|
||||
"game.shell.connection.reconnecting": "переподключение…",
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
type GameSummary,
|
||||
type InviteSummary,
|
||||
} from "../../api/lobby";
|
||||
import { ByteBuffer } from "flatbuffers";
|
||||
import { AccountResponse } from "../../proto/galaxy/fbs/user";
|
||||
import { AccountError, getMyAccount } from "../../api/account";
|
||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import {
|
||||
SyntheticReportError,
|
||||
@@ -27,10 +26,10 @@
|
||||
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";
|
||||
import LobbyShell from "./lobby-shell.svelte";
|
||||
|
||||
let displayName: string | null = $state(null);
|
||||
let displayName = $state("");
|
||||
let userName = $state("");
|
||||
let configError: string | null = $state(null);
|
||||
let listsLoading = $state(true);
|
||||
let lobbyError: string | null = $state(null);
|
||||
@@ -51,10 +50,6 @@
|
||||
|
||||
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);
|
||||
@@ -163,24 +158,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
async function loadIdentity(c: GalaxyClient): Promise<void> {
|
||||
try {
|
||||
const account = await getMyAccount(c);
|
||||
displayName = account.displayName;
|
||||
userName = account.userName;
|
||||
} catch (err) {
|
||||
if (err instanceof AccountError) {
|
||||
// Stay quiet: the lobby still works without a name; the
|
||||
// identity strip falls back to a loading placeholder.
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
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 {
|
||||
@@ -260,7 +250,7 @@
|
||||
deviceSessionId: session.deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
loadGreeting(client).catch(() => {});
|
||||
loadIdentity(client).catch(() => {});
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
lobbyError = describeLobbyError(err);
|
||||
@@ -269,24 +259,7 @@
|
||||
});
|
||||
</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>
|
||||
|
||||
<LobbyShell activePage="overview" {displayName} {userName}>
|
||||
{#if configError !== null}
|
||||
<p role="alert" data-testid="account-error">{configError}</p>
|
||||
{:else if lobbyError !== null}
|
||||
@@ -483,38 +456,16 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
</LobbyShell>
|
||||
|
||||
<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;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: var(--text-lg);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.card-list {
|
||||
@@ -523,16 +474,16 @@
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.4rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
@@ -552,54 +503,55 @@
|
||||
|
||||
.meta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.status {
|
||||
align-self: flex-start;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface-raised);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
font-size: 1rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: var(--text-md);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.synthetic-loader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px dashed var(--color-text-muted);
|
||||
border-radius: 0.4rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.synthetic-loader input[type="file"] {
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<!--
|
||||
Shared chrome for the post-login "site"-style pages — the lobby
|
||||
landing and the editable profile. Renders a left page-list sidebar
|
||||
(mirroring the project site's VitePress layout) plus a top identity
|
||||
strip ("Player-xxxx" → opens profile, logout). Children fill the
|
||||
right-hand column. Pages mark themselves active via `activePage`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
|
||||
type Page = "overview" | "profile";
|
||||
|
||||
interface Props {
|
||||
activePage: Page;
|
||||
displayName: string;
|
||||
userName: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { activePage, displayName, userName, children }: Props = $props();
|
||||
|
||||
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
|
||||
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
|
||||
{ id: "profile", labelKey: "lobby.nav.profile", screen: "profile" },
|
||||
];
|
||||
|
||||
let identityLabel = $derived.by(() => {
|
||||
const trimmed = displayName.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
if (userName.length > 0) return userName;
|
||||
return i18n.t("lobby.account_loading");
|
||||
});
|
||||
|
||||
function gotoPage(screen: "lobby" | "profile"): void {
|
||||
if (appScreen.screen !== screen) {
|
||||
appScreen.go(screen);
|
||||
}
|
||||
}
|
||||
|
||||
function gotoProfile(): void {
|
||||
gotoPage("profile");
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await session.signOut("user");
|
||||
}
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
||||
<div class="layout">
|
||||
<header class="topbar">
|
||||
<button
|
||||
type="button"
|
||||
class="identity"
|
||||
onclick={gotoProfile}
|
||||
data-testid="lobby-account-name"
|
||||
>
|
||||
{identityLabel}
|
||||
</button>
|
||||
<button type="button" class="logout" onclick={logout} data-testid="lobby-logout">
|
||||
{i18n.t("lobby.logout")}
|
||||
</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
|
||||
<ul>
|
||||
{#each PAGES as page (page.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link"
|
||||
class:active={activePage === page.id}
|
||||
aria-current={activePage === page.id ? "page" : undefined}
|
||||
onclick={() => gotoPage(page.screen)}
|
||||
data-testid="lobby-nav-{page.id}"
|
||||
>
|
||||
{i18n.t(page.labelKey)}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
<main id="main-content" tabindex="-1" class="content">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.identity {
|
||||
font: inherit;
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.identity:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.logout {
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 14rem;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar {
|
||||
flex: 0 0 auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.sidebar ul {
|
||||
flex-direction: row;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.nav-link {
|
||||
white-space: nowrap;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
}
|
||||
.content {
|
||||
padding: var(--space-4);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,333 @@
|
||||
<script lang="ts">
|
||||
// Profile screen: a top-level appScreen (peer of `lobby` and
|
||||
// `lobby-create`). Loads the caller's account aggregate, lets the
|
||||
// user edit `display_name`, `preferred_language`, and `time_zone`,
|
||||
// and posts the changes through `user.profile.update` /
|
||||
// `user.settings.update`. Returns to the lobby on save or cancel.
|
||||
import { onMount } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { createGatewayClient } from "../../api/connect";
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
import {
|
||||
AccountError,
|
||||
getMyAccount,
|
||||
updateMyProfile,
|
||||
updateMySettings,
|
||||
type Account,
|
||||
} from "../../api/account";
|
||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import {
|
||||
i18n,
|
||||
SUPPORTED_LOCALES,
|
||||
type Locale,
|
||||
type TranslationKey,
|
||||
} from "$lib/i18n/index.svelte";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import LobbyShell from "./lobby-shell.svelte";
|
||||
|
||||
let loaded: Account | null = $state(null);
|
||||
let displayNameInput = $state("");
|
||||
let preferredLanguageInput = $state("");
|
||||
let timeZoneInput = $state("");
|
||||
|
||||
let loadError: string | null = $state(null);
|
||||
let configError: string | null = $state(null);
|
||||
let saveError: string | null = $state(null);
|
||||
let saving = $state(false);
|
||||
|
||||
let client: GalaxyClient | null = null;
|
||||
|
||||
const SUPPORTED_LOCALE_CODES: ReadonlySet<string> = new Set(
|
||||
SUPPORTED_LOCALES.map((entry) => entry.code),
|
||||
);
|
||||
|
||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
function describe(err: unknown): string {
|
||||
if (err instanceof AccountError) {
|
||||
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 browserTimeZone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function applyAccount(account: Account): void {
|
||||
loaded = account;
|
||||
displayNameInput = account.displayName;
|
||||
preferredLanguageInput = account.preferredLanguage;
|
||||
timeZoneInput = account.timeZone;
|
||||
}
|
||||
|
||||
async function loadAccount(c: GalaxyClient): Promise<void> {
|
||||
try {
|
||||
applyAccount(await getMyAccount(c));
|
||||
} catch (err) {
|
||||
loadError = describe(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (client === null || loaded === null || saving) return;
|
||||
const trimmedDisplay = displayNameInput.trim();
|
||||
const trimmedLanguage = preferredLanguageInput.trim();
|
||||
const trimmedZone = timeZoneInput.trim();
|
||||
if (trimmedLanguage === "") {
|
||||
saveError = i18n.t("profile.error.language_required");
|
||||
return;
|
||||
}
|
||||
if (trimmedZone === "") {
|
||||
saveError = i18n.t("profile.error.time_zone_required");
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
saveError = null;
|
||||
try {
|
||||
let next: Account = loaded;
|
||||
if (trimmedDisplay !== loaded.displayName) {
|
||||
next = await updateMyProfile(client, trimmedDisplay);
|
||||
}
|
||||
if (
|
||||
trimmedLanguage !== loaded.preferredLanguage ||
|
||||
trimmedZone !== loaded.timeZone
|
||||
) {
|
||||
next = await updateMySettings(client, trimmedLanguage, trimmedZone);
|
||||
}
|
||||
applyAccount(next);
|
||||
// When the user picks a language the UI supports, switch the
|
||||
// active locale immediately so the rest of the session sees
|
||||
// the change without a reload. Unsupported BCP 47 codes are
|
||||
// saved on the account but leave the active locale alone.
|
||||
if (SUPPORTED_LOCALE_CODES.has(next.preferredLanguage)) {
|
||||
i18n.setLocale(next.preferredLanguage as Locale);
|
||||
}
|
||||
appScreen.go("lobby");
|
||||
} catch (err) {
|
||||
saveError = describe(err);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
appScreen.go("lobby");
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
});
|
||||
await loadAccount(client);
|
||||
} catch (err) {
|
||||
loadError = describe(err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<LobbyShell
|
||||
activePage="profile"
|
||||
displayName={loaded?.displayName ?? ""}
|
||||
userName={loaded?.userName ?? ""}
|
||||
>
|
||||
<h1>{i18n.t("profile.title")}</h1>
|
||||
{#if configError !== null}
|
||||
<p role="alert" data-testid="profile-config-error">{configError}</p>
|
||||
{:else if loaded === null && loadError === null}
|
||||
<p role="status" data-testid="profile-loading">{i18n.t("profile.loading")}</p>
|
||||
{:else if loadError !== null}
|
||||
<p role="alert" data-testid="profile-load-error">{loadError}</p>
|
||||
{:else if loaded !== null}
|
||||
<dl class="identity" data-testid="profile-identity">
|
||||
<dt>{i18n.t("profile.field.user_name")}</dt>
|
||||
<dd>{loaded.userName}</dd>
|
||||
<dt>{i18n.t("profile.field.email")}</dt>
|
||||
<dd>{loaded.email}</dd>
|
||||
</dl>
|
||||
|
||||
<form onsubmit={save} data-testid="profile-form">
|
||||
<label>
|
||||
<span>{i18n.t("profile.field.display_name")}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={displayNameInput}
|
||||
autocomplete="nickname"
|
||||
data-testid="profile-display-name"
|
||||
/>
|
||||
<small>{i18n.t("profile.hint.display_name")}</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{i18n.t("profile.field.preferred_language")}</span>
|
||||
<select
|
||||
bind:value={preferredLanguageInput}
|
||||
data-testid="profile-preferred-language"
|
||||
>
|
||||
{#each SUPPORTED_LOCALES as entry (entry.code)}
|
||||
<option value={entry.code}>{entry.nativeName}</option>
|
||||
{/each}
|
||||
{#if !SUPPORTED_LOCALE_CODES.has(preferredLanguageInput) && preferredLanguageInput !== ""}
|
||||
<!--
|
||||
Backend stores arbitrary BCP 47 tags, but the UI only
|
||||
ships translations for the codes in `SUPPORTED_LOCALES`.
|
||||
Preserve the saved value so saving the form unchanged
|
||||
does not silently switch it.
|
||||
-->
|
||||
<option value={preferredLanguageInput}>{preferredLanguageInput}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{i18n.t("profile.field.time_zone")}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={timeZoneInput}
|
||||
placeholder={browserTimeZone()}
|
||||
autocomplete="off"
|
||||
data-testid="profile-time-zone"
|
||||
/>
|
||||
<small>{i18n.t("profile.hint.time_zone")}</small>
|
||||
</label>
|
||||
|
||||
{#if saveError !== null}
|
||||
<p role="alert" data-testid="profile-save-error">{saveError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={saving} data-testid="profile-save">
|
||||
{saving ? i18n.t("profile.saving") : i18n.t("profile.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancel}
|
||||
disabled={saving}
|
||||
data-testid="profile-cancel"
|
||||
>
|
||||
{i18n.t("profile.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</LobbyShell>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--space-1) var(--space-4);
|
||||
margin: 0 0 var(--space-5);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.identity dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.identity dd {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
label > span {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
select {
|
||||
font: inherit;
|
||||
font-size: var(--text-md);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.actions button {
|
||||
font: inherit;
|
||||
font-size: var(--text-md);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions button[type="submit"] {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,7 @@
|
||||
import LoginScreen from "$lib/screens/login-screen.svelte";
|
||||
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
|
||||
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
|
||||
import ProfileScreen from "$lib/screens/profile-screen.svelte";
|
||||
import GameShell from "$lib/game/game-shell.svelte";
|
||||
import { pushState } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
@@ -67,6 +68,8 @@
|
||||
pushState("", { screen: "game", gameId: appScreen.gameId });
|
||||
} else if (appScreen.screen === "lobby-create") {
|
||||
pushState("", { screen: "lobby-create" });
|
||||
} else if (appScreen.screen === "profile") {
|
||||
pushState("", { screen: "profile" });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -83,6 +86,8 @@
|
||||
{#if session.status === "authenticated"}
|
||||
{#if appScreen.screen === "lobby-create"}
|
||||
<LobbyCreateScreen />
|
||||
{:else if appScreen.screen === "profile"}
|
||||
<ProfileScreen />
|
||||
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
|
||||
<GameShell />
|
||||
{:else}
|
||||
|
||||
@@ -159,9 +159,9 @@ async function completeLogin(page: Page): Promise<void> {
|
||||
await page.getByTestId("login-code-input").click();
|
||||
await page.getByTestId("login-code-input").fill("123456");
|
||||
await page.getByTestId("login-code-submit").click();
|
||||
// Sign-in switches the in-memory screen to the lobby; the device
|
||||
// session id surfaces only on the lobby screen.
|
||||
await expect(page.getByTestId("device-session-id")).toBeVisible();
|
||||
// Sign-in switches the in-memory screen to the lobby; the identity
|
||||
// strip rendered by `lobby-shell.svelte` is the lobby-loaded signal.
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Phase 7 — auth flow", () => {
|
||||
@@ -174,10 +174,7 @@ test.describe("Phase 7 — auth flow", () => {
|
||||
}) => {
|
||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||
await completeLogin(page);
|
||||
await expect(page.getByTestId("device-session-id")).toHaveText(
|
||||
"dev-test-1",
|
||||
);
|
||||
await expect(page.getByTestId("account-greeting")).toContainText("Pilot");
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
@@ -187,13 +184,13 @@ test.describe("Phase 7 — auth flow", () => {
|
||||
}) => {
|
||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||
await completeLogin(page);
|
||||
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
// The restored session re-renders the lobby screen directly (no
|
||||
// `/lobby` route to land on).
|
||||
await expect(page.getByTestId("device-session-id")).toHaveText(
|
||||
"dev-test-1",
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
||||
"Pilot",
|
||||
);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
@@ -204,7 +201,15 @@ test.describe("Phase 7 — auth flow", () => {
|
||||
}) => {
|
||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||
await completeLogin(page);
|
||||
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
||||
// `lobby-account-name` becomes visible on lobby mount with the
|
||||
// "loading account…" placeholder before the gateway responds.
|
||||
// Wait for the loaded name to settle so the event-stream effect
|
||||
// has had a chance to issue its `SubscribeEvents` request — the
|
||||
// release below targets that pending stream, and an empty
|
||||
// `pendingSubscribes` list defeats the test.
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
||||
"Pilot",
|
||||
);
|
||||
|
||||
// Fire all pending SubscribeEvents requests with an empty 200
|
||||
// response. Connect-Web's server-streaming reader sees no frames
|
||||
|
||||
@@ -215,6 +215,9 @@ export interface AccountFixture {
|
||||
email: string;
|
||||
userName: string;
|
||||
displayName: string;
|
||||
preferredLanguage?: string;
|
||||
timeZone?: string;
|
||||
declaredCountry?: string;
|
||||
}
|
||||
|
||||
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
|
||||
@@ -237,9 +240,9 @@ export function buildAccountResponsePayload(account: AccountFixture): Uint8Array
|
||||
const email = builder.createString(account.email);
|
||||
const userName = builder.createString(account.userName);
|
||||
const displayName = builder.createString(account.displayName);
|
||||
const preferredLanguage = builder.createString("en");
|
||||
const timeZone = builder.createString("UTC");
|
||||
const declaredCountry = builder.createString("");
|
||||
const preferredLanguage = builder.createString(account.preferredLanguage ?? "en");
|
||||
const timeZone = builder.createString(account.timeZone ?? "UTC");
|
||||
const declaredCountry = builder.createString(account.declaredCountry ?? "");
|
||||
AccountView.startAccountView(builder);
|
||||
AccountView.addUserId(builder, userId);
|
||||
AccountView.addEmail(builder, email);
|
||||
|
||||
@@ -251,7 +251,7 @@ async function completeLogin(page: Page): Promise<void> {
|
||||
await page.getByTestId("login-code-input").fill("123456");
|
||||
await page.getByTestId("login-code-submit").click();
|
||||
// Sign-in switches the in-memory screen to the lobby.
|
||||
await expect(page.getByTestId("device-session-id")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Phase 8 — lobby flow", () => {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the
|
||||
// lobby boots with an account aggregate, then exercises the sidebar
|
||||
// navigation into the profile, the edit form, and the save round-trip
|
||||
// against the FlatBuffers-decoded `user.profile.update` /
|
||||
// `user.settings.update` payloads.
|
||||
|
||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||
import { ByteBuffer } from "flatbuffers";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
|
||||
import {
|
||||
UpdateMyProfileRequest,
|
||||
UpdateMySettingsRequest,
|
||||
} from "../../src/proto/galaxy/fbs/user";
|
||||
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
||||
import {
|
||||
buildAccountResponsePayload,
|
||||
buildMyApplicationsListPayload,
|
||||
buildMyGamesListPayload,
|
||||
buildMyInvitesListPayload,
|
||||
buildPublicGamesListPayload,
|
||||
type AccountFixture,
|
||||
} from "./fixtures/lobby-fbs";
|
||||
|
||||
interface ProfileMocks {
|
||||
pendingSubscribes: Array<() => void>;
|
||||
account: AccountFixture;
|
||||
profileUpdates: Array<{ displayName: string }>;
|
||||
settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>;
|
||||
}
|
||||
|
||||
async function mockGateway(
|
||||
page: Page,
|
||||
initial: AccountFixture,
|
||||
): Promise<ProfileMocks> {
|
||||
const mocks: ProfileMocks = {
|
||||
pendingSubscribes: [],
|
||||
account: { ...initial },
|
||||
profileUpdates: [],
|
||||
settingsUpdates: [],
|
||||
};
|
||||
|
||||
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ challenge_id: "ch-test-1" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ device_session_id: "dev-test-1" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
|
||||
const reqText = route.request().postData();
|
||||
if (reqText === null) {
|
||||
await route.fulfill({ status: 400 });
|
||||
return;
|
||||
}
|
||||
const req = fromJson(
|
||||
ExecuteCommandRequestSchema,
|
||||
JSON.parse(reqText) as JsonValue,
|
||||
);
|
||||
let payload: Uint8Array;
|
||||
switch (req.messageType) {
|
||||
case "user.account.get":
|
||||
payload = buildAccountResponsePayload(mocks.account);
|
||||
break;
|
||||
case "user.profile.update": {
|
||||
const decoded = UpdateMyProfileRequest.getRootAsUpdateMyProfileRequest(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
const next = decoded.displayName() ?? "";
|
||||
mocks.profileUpdates.push({ displayName: next });
|
||||
mocks.account = { ...mocks.account, displayName: next };
|
||||
payload = buildAccountResponsePayload(mocks.account);
|
||||
break;
|
||||
}
|
||||
case "user.settings.update": {
|
||||
const decoded = UpdateMySettingsRequest.getRootAsUpdateMySettingsRequest(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
const preferredLanguage = decoded.preferredLanguage() ?? "";
|
||||
const timeZone = decoded.timeZone() ?? "";
|
||||
mocks.settingsUpdates.push({ preferredLanguage, timeZone });
|
||||
mocks.account = { ...mocks.account, preferredLanguage, timeZone };
|
||||
payload = buildAccountResponsePayload(mocks.account);
|
||||
break;
|
||||
}
|
||||
case "lobby.my.games.list":
|
||||
payload = buildMyGamesListPayload([]);
|
||||
break;
|
||||
case "lobby.public.games.list":
|
||||
payload = buildPublicGamesListPayload([]);
|
||||
break;
|
||||
case "lobby.my.invites.list":
|
||||
payload = buildMyInvitesListPayload([]);
|
||||
break;
|
||||
case "lobby.my.applications.list":
|
||||
payload = buildMyApplicationsListPayload([]);
|
||||
break;
|
||||
default:
|
||||
payload = new Uint8Array();
|
||||
}
|
||||
const responseJson = await forgeExecuteCommandResponseJson({
|
||||
requestId: req.requestId,
|
||||
timestampMs: BigInt(Date.now()),
|
||||
resultCode: "ok",
|
||||
payloadBytes: payload,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: responseJson,
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
|
||||
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
|
||||
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
|
||||
});
|
||||
if (action === "abort") {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
const body = new TextEncoder().encode("{}");
|
||||
const frame = new Uint8Array(5 + body.length);
|
||||
frame[0] = 0x02;
|
||||
new DataView(frame.buffer).setUint32(1, body.length, false);
|
||||
frame.set(body, 5);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/connect+json",
|
||||
body: Buffer.from(frame),
|
||||
});
|
||||
});
|
||||
|
||||
return mocks;
|
||||
}
|
||||
|
||||
async function completeLogin(page: Page): Promise<void> {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("login-email-input")).toBeVisible();
|
||||
await page.getByTestId("login-email-input").click();
|
||||
await page.getByTestId("login-email-input").fill("pilot@example.com");
|
||||
await page.getByTestId("login-email-submit").click();
|
||||
await expect(page.getByTestId("login-code-input")).toBeVisible();
|
||||
await page.getByTestId("login-code-input").click();
|
||||
await page.getByTestId("login-code-input").fill("123456");
|
||||
await page.getByTestId("login-code-submit").click();
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("F8-04 — profile screen", () => {
|
||||
test("clicking the identity strip opens the profile and renders the form", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
||||
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||
await expect(page.getByTestId("profile-display-name")).toHaveValue("Pilot");
|
||||
await expect(page.getByTestId("profile-identity")).toContainText(
|
||||
"player-abc12345",
|
||||
);
|
||||
await expect(page.getByTestId("profile-identity")).toContainText(
|
||||
"pilot@example.com",
|
||||
);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
|
||||
test("saving an edited display name posts user.profile.update and returns to lobby", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||
|
||||
await page.getByTestId("profile-display-name").fill("Captain");
|
||||
await page.getByTestId("profile-save").click();
|
||||
|
||||
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
|
||||
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
||||
"Captain",
|
||||
);
|
||||
expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]);
|
||||
expect(mocks.settingsUpdates).toEqual([]);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
|
||||
test("changing the language posts user.settings.update and switches the active locale", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
|
||||
await page.getByTestId("profile-preferred-language").selectOption("ru");
|
||||
await page.getByTestId("profile-save").click();
|
||||
|
||||
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||
// The lobby switches to the Russian dictionary after the save —
|
||||
// the "create new game" button label is the visible signal.
|
||||
await expect(page.getByTestId("lobby-create-button")).toHaveText(
|
||||
"создать новую игру",
|
||||
);
|
||||
expect(mocks.settingsUpdates).toEqual([
|
||||
{ preferredLanguage: "ru", timeZone: "UTC" },
|
||||
]);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
|
||||
test("cancel returns to the lobby without posting anything", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mocks = await mockGateway(page, {
|
||||
userId: "user-1",
|
||||
email: "pilot@example.com",
|
||||
userName: "player-abc12345",
|
||||
displayName: "Pilot",
|
||||
});
|
||||
await completeLogin(page);
|
||||
await page.getByTestId("lobby-account-name").click();
|
||||
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||
|
||||
await page.getByTestId("profile-display-name").fill("ignored");
|
||||
await page.getByTestId("profile-cancel").click();
|
||||
|
||||
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
|
||||
expect(mocks.profileUpdates).toEqual([]);
|
||||
expect(mocks.settingsUpdates).toEqual([]);
|
||||
|
||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user