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
221 lines
4.9 KiB
Svelte
221 lines
4.9 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`.
|
|
-->
|
|
<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>
|