Files
galaxy-game/ui/frontend/src/lib/screens/profile-screen.svelte
T
Ilia Denisov a679d9cdcb
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m30s
Tests · UI / test (pull_request) Successful in 2m49s
fix(ui): F8-04 profile polish — IANA timezone picker, save-stay, shared identity cache
PR-feedback round on #60:

- Time-zone field is now a continent-grouped <select> populated from
  `Intl.supportedValuesOf("timeZone")`, with the browser-detected
  zone pre-selected when no value is stored. A stored zone the
  runtime no longer advertises is preserved as an "Other" entry.
- Saving the profile no longer kicks the user back to the lobby:
  the form stays put and shows a transient `saved` notice, cleared
  on the next edit. Only `cancel` returns to the lobby.
- New `lib/account-store.svelte.ts` caches `user.account.get` for
  the session; lobby + profile share it through `account.ensure()`,
  so navigating Overview ⇄ Profile no longer flashes the
  "loading account…" placeholder or fires a second gateway call.
  Profile save writes through to the store so the shell identity
  strip picks up the new display name without refetching. Cleared
  on logout to prevent identity bleed between accounts.
- e2e: existing 4 cases adjusted for save-stay; added two new ones
  for the timezone dropdown and identity-strip stability across
  navigation.
- Docs: `ui/docs/lobby.md` updated to describe the shared cache,
  the new timezone picker shape, and the save-stay behaviour.
2026-05-26 22:38:14 +02:00

388 lines
11 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`. The form stays on screen after a
// successful save (the shell-level identity strip picks up the
// new value through the shared `account` store) — only `cancel`
// returns to the lobby.
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import {
AccountError,
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 { account } from "$lib/account-store.svelte";
import {
browserTimeZone,
supportedTimeZones,
withPreservedValue,
type TimeZoneGroup,
} from "$lib/time-zones";
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 savedNotice = $state(false);
let client: GalaxyClient | null = null;
const SUPPORTED_LOCALE_CODES: ReadonlySet<string> = new Set(
SUPPORTED_LOCALES.map((entry) => entry.code),
);
// Built once: the IANA list is static for the page lifetime. The
// stored value is folded in lazily so a zone the runtime no longer
// advertises still renders.
const TIME_ZONE_GROUPS_BASE: readonly TimeZoneGroup[] = supportedTimeZones();
let timeZoneGroups = $derived<readonly TimeZoneGroup[]>(
withPreservedValue(TIME_ZONE_GROUPS_BASE, timeZoneInput),
);
let timeZoneFallbackToText = $derived(TIME_ZONE_GROUPS_BASE.length === 0);
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 applyAccount(next: Account): void {
loaded = next;
displayNameInput = next.displayName;
preferredLanguageInput = next.preferredLanguage;
// Seed an empty stored zone with the browser's current zone so
// the picker lands on a sensible default rather than the first
// IANA entry. The form treats "no change" as not posting, so
// the seeded value is only persisted on an explicit save.
timeZoneInput = next.timeZone.length > 0 ? next.timeZone : browserTimeZone();
}
function markDirty(): void {
// Any edit invalidates the "Saved" notice.
savedNotice = false;
saveError = null;
}
async function loadAccount(c: GalaxyClient): Promise<void> {
try {
applyAccount(await account.ensure(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;
savedNotice = false;
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);
account.set(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);
}
savedNotice = true;
} 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">
<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}
oninput={markDirty}
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}
onchange={markDirty}
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>
{#if timeZoneFallbackToText}
<!--
Browser lacks `Intl.supportedValuesOf("timeZone")` —
fall back to a free-text field so a viable runtime can
still save a zone. The backend remains the validator.
-->
<input
type="text"
bind:value={timeZoneInput}
oninput={markDirty}
placeholder={browserTimeZone()}
autocomplete="off"
data-testid="profile-time-zone"
/>
{:else}
<select
bind:value={timeZoneInput}
onchange={markDirty}
data-testid="profile-time-zone"
>
{#each timeZoneGroups as group (group.label)}
<optgroup label={group.label}>
{#each group.values as zone (zone)}
<option value={zone}>{zone}</option>
{/each}
</optgroup>
{/each}
</select>
{/if}
<small>{i18n.t("profile.hint.time_zone")}</small>
</label>
{#if saveError !== null}
<p role="alert" data-testid="profile-save-error">{saveError}</p>
{:else if savedNotice}
<p role="status" data-testid="profile-saved-notice">
{i18n.t("profile.saved")}
</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);
}
[data-testid="profile-saved-notice"] {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin: 0;
}
.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>