Files
galaxy-game/ui/frontend/src/lib/screens/profile-screen.svelte
T
Ilia Denisov 009ea560f9
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run
feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Reshape the lobby UI from a single Overview into a two-level sidebar
(games · profile · DEV synthetic-reports) with four games sub-panels
(active-past · recruitment · invitations · private-games). Move the
`create new game` button into the private-games panel, merge the
applications section into recruitment cards as status chips, and add
DEV-only synthetic-report loader as a top-level screen.

Add a paid-tier gate at backend `lobby.game.create`: free callers get
`403 forbidden` before the lobby service is invoked. The UI hides the
private-games sub-panel + create button on free tier (DEV affordances
flag overrides). Update every integration test that creates a game to
use a new `testenv.PromoteToPaid` helper; add a new
`TestLobbyFlow_FreeUserCreateGameForbidden`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:53:53 +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>
<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>