2ecdecad1e
- 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
334 lines
8.8 KiB
Svelte
334 lines
8.8 KiB
Svelte
<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>
|