a679d9cdcb
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.
228 lines
5.3 KiB
Svelte
228 lines
5.3 KiB
Svelte
<!--
|
|
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`.
|
|
|
|
The identity strip reads directly from the session-wide `account`
|
|
store so navigating Overview ⇄ Profile never re-renders an empty
|
|
placeholder: both screens populate the same cache through
|
|
`account.ensure(client)` and the shell renders the latest value.
|
|
-->
|
|
<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";
|
|
import { account } from "$lib/account-store.svelte";
|
|
|
|
type Page = "overview" | "profile";
|
|
|
|
interface Props {
|
|
activePage: Page;
|
|
children: Snippet;
|
|
}
|
|
|
|
let { activePage, 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 current = account.current;
|
|
if (current !== null) {
|
|
const trimmed = current.displayName.trim();
|
|
if (trimmed.length > 0) return trimmed;
|
|
if (current.userName.length > 0) return current.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>
|