Files
galaxy-game/ui/frontend/src/lib/screens/lobby-shell.svelte
T
Ilia Denisov 2ecdecad1e 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
2026-05-26 22:25:40 +02:00

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>