feat(ui): lobby site-style sidebar + profile screen (#47)
- 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
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
<!--
|
||||
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>
|
||||
Reference in New Issue
Block a user