6fbab5417f
Two follow-up nits on the F8-04b sidebar: 1. The bare-`lobby` resolver (lobby-screen.svelte) redirected to `games-recruitment` unconditionally on mount. With games already in the player's roster the sidebar then highlighted the wrong sub-page. The resolver now awaits the lobby fan-out + account fetch, then hands off to the same `firstVisibleGamesScreen` helper the sidebar uses — so a fresh entry with games lands on `active-past`, the canonical-order fallback stays `recruitment`. 2. `games-invitations` was unconditionally visible in the sidebar. Now it follows the `active-past` rule: hidden until the pending-invites list reports >=1. The lobby shell's auto-kick effect treats it symmetrically — accepting / declining the last invite moves the player to the next visible sub-page once the fan-out has resolved. Acceptance order in games-invitations-screen.acceptInvite was also swapped to setMyGames-before-removeInvitation: both mutations land in the same microtask, so the new auto-kick sees the freshly added game in `myGames` when invitations drop to zero and routes the player to `active-past` instead of bouncing through `recruitment`. The visibility predicates and canonical order live in the new `src/lib/lobby-nav.ts` pure helper, shared between the sidebar and the resolver so they cannot disagree. Unit tests cover every combination of (hasMyGames, hasInvitations, isPaidOrDev). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
541 lines
14 KiB
Svelte
541 lines
14 KiB
Svelte
<!--
|
|
Shared chrome for the post-login lobby pages and the profile screen.
|
|
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.
|
|
|
|
F8-04b extends the sidebar to a two-level hierarchy:
|
|
- top-level items: `games` (with submenu), `profile`, and DEV-only
|
|
`synthetic test reports`;
|
|
- `games` submenu: `active-past` (hidden when the player has no games),
|
|
`recruitment` (always), `invitations` (hidden when no pending invites),
|
|
`private games` (paid-tier only; DEV overrides). The visibility rules
|
|
live in `lib/lobby-nav.ts` so the bare-`lobby` resolver
|
|
(`lobby-screen.svelte`) and this shell agree on the auto-expanded
|
|
sub-page.
|
|
|
|
Desktop (>640px): the submenu stays expanded as long as the active
|
|
screen is one of the games sub-panels. Mobile (≤640px, existing
|
|
horizontal-strip breakpoint): the `games` item becomes a dropdown
|
|
labeled "games · {active-sub} ▾"; tapping toggles the popover, tapping
|
|
outside or pressing Escape closes it, and re-selecting the active
|
|
sub-item is a no-op (mirrors the F8-02 idiom from issue #45).
|
|
|
|
The identity strip reads directly from the session-wide `account`
|
|
store so navigating between sub-pages never re-renders an empty
|
|
placeholder.
|
|
-->
|
|
<script lang="ts">
|
|
import { onMount, type Snippet } from "svelte";
|
|
import { appScreen, isLobbySubScreen, type AppScreen } from "$lib/app-nav.svelte";
|
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
|
import { session } from "$lib/session-store.svelte";
|
|
import { account } from "$lib/account-store.svelte";
|
|
import { lobbyData } from "$lib/lobby-data.svelte";
|
|
import {
|
|
firstVisibleGamesScreen,
|
|
visibleGamesSubScreens,
|
|
type GamesSubScreen,
|
|
type LobbyNavState,
|
|
} from "$lib/lobby-nav";
|
|
|
|
interface Props {
|
|
children: Snippet;
|
|
}
|
|
|
|
let { children }: Props = $props();
|
|
|
|
const DEV_AFFORDANCES =
|
|
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
|
|
|
|
const SUB_LABELS: Record<GamesSubScreen, TranslationKey> = {
|
|
"games-active-past": "lobby.nav.games.active_past",
|
|
"games-recruitment": "lobby.nav.games.recruitment",
|
|
"games-invitations": "lobby.nav.games.invitations",
|
|
"games-private-games": "lobby.nav.games.private_games",
|
|
};
|
|
|
|
let navState = $derived.by<LobbyNavState>(() => ({
|
|
hasMyGames: lobbyData.myGames.length > 0,
|
|
hasInvitations: lobbyData.invitations.length > 0,
|
|
isPaidOrDev:
|
|
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
|
|
}));
|
|
|
|
let visibleSubs = $derived(visibleGamesSubScreens(navState));
|
|
let firstVisible = $derived(firstVisibleGamesScreen(navState));
|
|
|
|
function gotoScreen(screen: AppScreen): void {
|
|
if (appScreen.screen !== screen) {
|
|
appScreen.go(screen);
|
|
}
|
|
}
|
|
|
|
function gotoGamesParent(): void {
|
|
gotoScreen(firstVisible);
|
|
}
|
|
|
|
let activeGamesSub = $derived.by<GamesSubScreen | null>(() => {
|
|
const s = appScreen.screen;
|
|
if (
|
|
s === "games-active-past" ||
|
|
s === "games-recruitment" ||
|
|
s === "games-invitations" ||
|
|
s === "games-private-games"
|
|
) {
|
|
return s;
|
|
}
|
|
return null;
|
|
});
|
|
|
|
let gamesActive = $derived.by(() => isLobbySubScreen(appScreen.screen));
|
|
let profileActive = $derived.by(() => appScreen.screen === "profile");
|
|
let syntheticActive = $derived.by(() => appScreen.screen === "synthetic-reports");
|
|
|
|
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 gotoProfile(): void {
|
|
gotoScreen("profile");
|
|
}
|
|
|
|
async function logout(): Promise<void> {
|
|
await session.signOut("user");
|
|
}
|
|
|
|
// Mobile dropdown state for the games submenu. Closed on subitem
|
|
// selection, click outside, Escape, and on tap of the toggle when
|
|
// already open.
|
|
let mobileMenuOpen = $state(false);
|
|
let mobileMenuEl: HTMLElement | null = null;
|
|
|
|
function toggleMobileMenu(): void {
|
|
mobileMenuOpen = !mobileMenuOpen;
|
|
}
|
|
|
|
function selectMobileSub(screen: AppScreen): void {
|
|
mobileMenuOpen = false;
|
|
gotoScreen(screen);
|
|
}
|
|
|
|
function handleMobileKeydown(event: KeyboardEvent): void {
|
|
if (event.key === "Escape" && mobileMenuOpen) {
|
|
mobileMenuOpen = false;
|
|
}
|
|
}
|
|
|
|
function handleDocumentPointerDown(event: PointerEvent): void {
|
|
if (!mobileMenuOpen) return;
|
|
if (mobileMenuEl !== null && event.target instanceof Node) {
|
|
if (!mobileMenuEl.contains(event.target)) {
|
|
mobileMenuOpen = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
document.addEventListener("pointerdown", handleDocumentPointerDown);
|
|
document.addEventListener("keydown", handleMobileKeydown);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handleDocumentPointerDown);
|
|
document.removeEventListener("keydown", handleMobileKeydown);
|
|
};
|
|
});
|
|
|
|
// Persisted snapshot may restore the user onto `games-private-games`
|
|
// after a tier downgrade or onto `synthetic-reports` in a prod
|
|
// bundle that strips the DEV affordance. Resolve quietly to the
|
|
// first visible games sub-page instead of letting the dispatcher
|
|
// render an empty shell.
|
|
$effect(() => {
|
|
const screen = appScreen.screen;
|
|
if (screen === "games-private-games") {
|
|
if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) {
|
|
appScreen.go(firstVisible);
|
|
}
|
|
} else if (screen === "synthetic-reports") {
|
|
if (!DEV_AFFORDANCES) {
|
|
appScreen.go(firstVisible);
|
|
}
|
|
} else if (screen === "games-active-past") {
|
|
// Hide-when-empty is asymmetric: we only kick the user out if
|
|
// the lobby-data store has actually reported zero games (not
|
|
// during the initial `loading=true` window). Otherwise a
|
|
// fresh navigation would bounce off this screen before the
|
|
// fan-out resolves.
|
|
if (!lobbyData.loading && lobbyData.myGames.length === 0) {
|
|
appScreen.go(firstVisible);
|
|
}
|
|
} else if (screen === "games-invitations") {
|
|
// Same asymmetric rule as `games-active-past`: hide-when-empty
|
|
// fires only after the fan-out resolves, so a fresh nav onto
|
|
// invitations does not bounce while the list is still loading.
|
|
// An invite redeemed / declined from this screen drops the
|
|
// only pending entry → user is moved to the first visible
|
|
// sub-page on the next tick.
|
|
if (!lobbyData.loading && lobbyData.invitations.length === 0) {
|
|
appScreen.go(firstVisible);
|
|
}
|
|
}
|
|
});
|
|
</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 class="top-list">
|
|
<li class="games-item">
|
|
<button
|
|
type="button"
|
|
class="nav-link parent"
|
|
class:active={gamesActive}
|
|
aria-current={gamesActive ? "page" : undefined}
|
|
onclick={gotoGamesParent}
|
|
data-testid="lobby-nav-games"
|
|
>
|
|
{i18n.t("lobby.nav.games")}
|
|
</button>
|
|
|
|
<!-- Desktop submenu: always expanded when in `games-*` -->
|
|
<ul
|
|
class="submenu desktop-only"
|
|
aria-label={i18n.t("lobby.nav.games.aria_label")}
|
|
>
|
|
{#each visibleSubs as sub (sub)}
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class="nav-link sub"
|
|
class:active={appScreen.screen === sub}
|
|
aria-current={appScreen.screen === sub ? "page" : undefined}
|
|
onclick={() => gotoScreen(sub)}
|
|
data-testid="lobby-nav-{sub}"
|
|
>
|
|
{i18n.t(SUB_LABELS[sub])}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
<!-- Mobile dropdown: only the active sub is shown as a
|
|
button, tap toggles the popover with the visible
|
|
subs. Re-tap on the active sub is a no-op. -->
|
|
<div
|
|
class="mobile-dropdown mobile-only"
|
|
bind:this={mobileMenuEl}
|
|
>
|
|
<button
|
|
type="button"
|
|
class="nav-link mobile-toggle"
|
|
class:active={gamesActive}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={mobileMenuOpen}
|
|
onclick={toggleMobileMenu}
|
|
data-testid="lobby-nav-games-mobile"
|
|
>
|
|
{i18n.t("lobby.nav.games.mobile_toggle", {
|
|
label: i18n.t(
|
|
SUB_LABELS[activeGamesSub ?? "games-recruitment"],
|
|
),
|
|
})}
|
|
<span aria-hidden="true">▾</span>
|
|
</button>
|
|
{#if mobileMenuOpen}
|
|
<ul
|
|
class="mobile-popover"
|
|
role="listbox"
|
|
aria-label={i18n.t("lobby.nav.games.aria_label")}
|
|
>
|
|
{#each visibleSubs as sub (sub)}
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class="popover-item"
|
|
class:active={appScreen.screen === sub}
|
|
role="option"
|
|
aria-selected={appScreen.screen === sub}
|
|
onclick={() => selectMobileSub(sub)}
|
|
data-testid="lobby-nav-{sub}-mobile"
|
|
>
|
|
{i18n.t(SUB_LABELS[sub])}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
</li>
|
|
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class="nav-link"
|
|
class:active={profileActive}
|
|
aria-current={profileActive ? "page" : undefined}
|
|
onclick={gotoProfile}
|
|
data-testid="lobby-nav-profile"
|
|
>
|
|
{i18n.t("lobby.nav.profile")}
|
|
</button>
|
|
</li>
|
|
|
|
{#if DEV_AFFORDANCES}
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class="nav-link"
|
|
class:active={syntheticActive}
|
|
aria-current={syntheticActive ? "page" : undefined}
|
|
onclick={() => gotoScreen("synthetic-reports")}
|
|
data-testid="lobby-nav-synthetic-reports"
|
|
>
|
|
{i18n.t("lobby.nav.synthetic_reports")}
|
|
</button>
|
|
</li>
|
|
{/if}
|
|
</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);
|
|
}
|
|
|
|
.top-list,
|
|
.submenu {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
.submenu {
|
|
margin-left: var(--space-3);
|
|
margin-top: 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.sub {
|
|
font-size: var(--text-sm);
|
|
padding: var(--space-1) var(--space-3);
|
|
}
|
|
|
|
.nav-link.parent.active {
|
|
color: var(--color-text);
|
|
font-weight: var(--weight-medium);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.mobile-dropdown {
|
|
position: relative;
|
|
display: none;
|
|
}
|
|
|
|
.mobile-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-1);
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.mobile-popover {
|
|
position: absolute;
|
|
top: calc(100% + var(--space-1));
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 5;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: var(--space-1);
|
|
background: var(--color-surface-raised);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-sm, 0 4px 12px rgba(0, 0, 0, 0.15));
|
|
}
|
|
|
|
.popover-item {
|
|
display: block;
|
|
width: 100%;
|
|
text-align: left;
|
|
font: inherit;
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text-muted);
|
|
background: transparent;
|
|
border: none;
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.popover-item:hover {
|
|
background: var(--color-surface-hover);
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.popover-item.active {
|
|
color: var(--color-accent);
|
|
background: var(--color-accent-subtle);
|
|
font-weight: var(--weight-medium);
|
|
}
|
|
|
|
.mobile-only {
|
|
display: none;
|
|
}
|
|
|
|
@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);
|
|
}
|
|
.top-list {
|
|
flex-direction: row;
|
|
gap: var(--space-2);
|
|
overflow-x: auto;
|
|
}
|
|
.nav-link {
|
|
white-space: nowrap;
|
|
padding: var(--space-1) var(--space-3);
|
|
}
|
|
.games-item {
|
|
position: relative;
|
|
}
|
|
.desktop-only {
|
|
display: none;
|
|
}
|
|
.mobile-only,
|
|
.mobile-dropdown {
|
|
display: block;
|
|
}
|
|
.content {
|
|
padding: var(--space-4);
|
|
max-width: none;
|
|
}
|
|
/* The games-item's parent button is replaced by the mobile
|
|
dropdown toggle. */
|
|
.nav-link.parent {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|