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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user