Stage 7 polish: app shell + nav + lobby + settings (Parts A/B/C)

- Screen.svelte shell: nav bar grows, ad+content+tabbar pinned bottom (mobile feel)
- AdBanner.svelte + banner.ts rotator (params, mock long/short, linkify); Header CSS chevron + grow; Menu (bigger CSS hamburger); TabBar + HoldConfirm shared components; user-select:none
- Lobby: hide-empty sections, tab order New/Tournaments/Stats, place-based result badges (result.ts)
- Settings: Board style > Labels (beginner/classic/none) + prefs plumbing (boardlabels.ts); i18n keys + ru mirror
This commit is contained in:
Ilia Denisov
2026-06-03 13:20:56 +02:00
parent 03347c5a91
commit 38be7fea96
18 changed files with 871 additions and 244 deletions
+8 -7
View File
@@ -1,16 +1,17 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte';
const version = '0.7.0';
</script>
<Header title={t('about.title')} back="/" />
<main class="page">
<h2>{t('app.title')}</h2>
<p>{t('about.description')}</p>
<p class="muted">{t('about.version', { v: version })}</p>
</main>
<Screen title={t('about.title')} back="/">
<div class="page">
<h2>{t('app.title')}</h2>
<p>{t('about.description')}</p>
<p class="muted">{t('about.version', { v: version })}</p>
</div>
</Screen>
<style>
.page {
+59 -143
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import Header from '../components/Header.svelte';
import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import type { GameView } from '../lib/model';
let games = $state<GameView[]>([]);
let menuOpen = $state(false);
async function load() {
try {
@@ -19,8 +21,6 @@
}
onMount(load);
// Refresh the lists when a live event lands (move / your-turn / match-found).
$effect(() => {
if (app.lastEvent) void load();
});
@@ -29,95 +29,72 @@
const active = $derived(games.filter((g) => g.status === 'active'));
const finished = $derived(games.filter((g) => g.status !== 'active'));
function mySeat(g: GameView) {
return g.seats.find((s) => s.accountId === myId);
}
function opponents(g: GameView): string {
return g.seats
.filter((s) => s.accountId !== myId)
.map((s) => s.displayName)
.join(', ');
}
function subtitle(g: GameView): string {
const me = mySeat(g);
if (g.status === 'active') {
return g.toMove === me?.seat ? t('lobby.yourTurn') : t('lobby.theirTurn');
}
if (me?.isWinner) return t('game.won');
return g.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
function scoreline(g: GameView): string {
const me = mySeat(g);
const me = g.seats.find((s) => s.accountId === myId);
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
return `${me?.score ?? 0} : ${opp.join(', ')}`;
}
function go(path: string) {
menuOpen = false;
navigate(path);
}
const menuItems = $derived([
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
{ label: t('lobby.settings'), onclick: () => navigate('/settings') },
{ label: t('lobby.about'), onclick: () => navigate('/about') },
]);
</script>
<Header title={app.profile?.displayName ?? t('app.title')}>
<Screen title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => go('/profile')}>{t('lobby.profile')}</button>
<button onclick={() => go('/settings')}>{t('lobby.settings')}</button>
<button onclick={() => go('/about')}>{t('lobby.about')}</button>
</div>
{/if}
<Menu items={menuItems} />
{/snippet}
</Header>
<main class="lobby">
<section>
<h2>{t('lobby.activeGames')}</h2>
{#if active.length === 0}
<div class="lobby">
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
{#if group.list.length}
<section>
<h2>{t(group.h as 'lobby.activeGames')}</h2>
{#each group.list as g (g.id)}
{@const b = resultBadge(g, myId)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
</span>
<span class="emoji">{b.emoji}</span>
</button>
{/each}
</section>
{/if}
{/each}
{#if !active.length && !finished.length}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
{#each active as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub" class:turn={g.toMove === mySeat(g)?.seat}>{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button>
{/each}
</section>
</div>
<section>
<h2>{t('lobby.finishedGames')}</h2>
{#if finished.length === 0}
<p class="empty">{t('lobby.noFinished')}</p>
{/if}
{#each finished as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub">{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
{#snippet tabbar()}
<TabBar>
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
{/each}
</section>
</main>
<nav class="tabs">
<button class="tab primary" onclick={() => navigate('/new')}>{t('lobby.new')}</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.stats')}</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.tournaments')}</button>
</nav>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">⚔️</span><span class="lbl">{t('lobby.tournaments')}</span>
</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">🧮</span><span class="lbl">{t('lobby.stats')}</span>
</button>
</TabBar>
{/snippet}
</Screen>
<style>
.lobby {
padding: var(--pad);
padding-bottom: 84px;
display: flex;
flex-direction: column;
gap: 18px;
@@ -147,88 +124,27 @@
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
user-select: none;
}
.info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.who {
font-weight: 600;
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
}
.sub.turn {
color: var(--accent);
font-weight: 600;
}
.score {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 0.85rem;
}
.tabs {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 8px;
padding: 10px var(--pad);
background: var(--bg-elev);
border-top: 1px solid var(--border);
}
.tab {
flex: 1;
padding: 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.tab.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
.emoji {
font-size: 1.8rem;
line-height: 1;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
flex: 0 0 auto;
}
</style>
+20 -20
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Header from '../components/Header.svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { handleError } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
@@ -31,8 +31,6 @@
navigate(`/game/${r.game.id}`);
return;
}
// The match also arrives via the live stream (handled in app), but poll as a
// fallback for a client that is not currently streaming.
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
@@ -53,23 +51,24 @@
onDestroy(stop);
</script>
<Header title={t('new.title')} back="/" />
<main class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div>
{:else}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each}
</div>
{/if}
</main>
<Screen title={t('new.title')} back="/">
<div class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div>
{:else}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each}
</div>
{/if}
</div>
</Screen>
<style>
.page {
@@ -92,6 +91,7 @@
border-radius: var(--radius);
font-size: 1.05rem;
font-weight: 600;
user-select: none;
}
.searching {
display: grid;
+19 -18
View File
@@ -1,26 +1,27 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import Screen from '../components/Screen.svelte';
import { app, logout } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
</script>
<Header title={t('profile.title')} back="/" />
<main class="page">
{#if app.profile}
<div class="name">{app.profile.displayName}</div>
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<dl>
<dt>{t('profile.language')}</dt>
<dd>{app.profile.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt>
<dd>{app.profile.timeZone}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{app.profile.hintBalance}</dd>
</dl>
<p class="muted">{t('profile.readonly')}</p>
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</main>
<Screen title={t('profile.title')} back="/">
<div class="page">
{#if app.profile}
<div class="name">{app.profile.displayName}</div>
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<dl>
<dt>{t('profile.language')}</dt>
<dd>{app.profile.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt>
<dd>{app.profile.timeZone}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{app.profile.hintBalance}</dd>
</dl>
<p class="muted">{t('profile.readonly')}</p>
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</div>
</Screen>
<style>
.page {
+67 -35
View File
@@ -1,8 +1,15 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import { app, setLocalePref, setReduceMotion, setTheme } from '../lib/app.svelte';
import Screen from '../components/Screen.svelte';
import {
app,
setBoardLabels,
setLocalePref,
setReduceMotion,
setTheme,
} from '../lib/app.svelte';
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
@@ -11,43 +18,62 @@
dark: 'settings.themeDark',
};
const locales: Locale[] = ['en', 'ru'];
const labelModes: BoardLabelMode[] = ['beginner', 'classic', 'none'];
const labelModeKey: Record<BoardLabelMode, MessageKey> = {
beginner: 'settings.labelsBeginner',
classic: 'settings.labelsClassic',
none: 'settings.labelsNone',
};
</script>
<Header title={t('settings.title')} back="/" />
<main class="page">
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
<Screen title={t('settings.title')} back="/">
<div class="page">
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
<section>
<h3>{t('settings.language')}</h3>
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button>
{/each}
</div>
</section>
<section>
<h3>{t('settings.language')}</h3>
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button>
{/each}
</div>
</section>
<section>
<label class="row">
<span>{t('settings.reduceMotion')}</span>
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
</section>
</main>
<section>
<h3>{t('settings.boardStyle')}</h3>
<div class="sub">{t('settings.boardLabels')}</div>
<div class="seg">
{#each labelModes as lm (lm)}
<button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
{t(labelModeKey[lm])}
</button>
{/each}
</div>
</section>
<section>
<label class="row">
<span>{t('settings.reduceMotion')}</span>
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
</section>
</div>
</Screen>
<style>
.page {
@@ -61,6 +87,11 @@
font-size: 0.95rem;
color: var(--text-muted);
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 6px;
}
.seg {
display: flex;
gap: 8px;
@@ -72,6 +103,7 @@
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
user-select: none;
}
.opt.active {
background: var(--accent);