Stage 7 (wip): UI shell, libs, mock transport, screens (lobby->game), e2e smoke
- plain Svelte 5 + TS + Vite (no SvelteKit); CSS-token design system (Telegram-ready), hash router, IndexedDB session - pure libs: domain model, premium/value maps ported from solver, board replay, placement state machine, i18n en/ru - in-memory mock transport + seed data; pnpm start runs lobby->active game->board with no backend - board: pointer-drag + tap placement, MakeMove (popup / 1s-hold commit), two-state zoom, blank chooser, exchange, hint, word-check, chat - Playwright smoke (mock) green; svelte-check clean; mock bundle ~37 KB gzip
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import Header from '../components/Header.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>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--pad);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Header from '../components/Header.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 type { GameView } from '../lib/model';
|
||||
|
||||
let games = $state<GameView[]>([]);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
games = (await gateway.gamesList()).games;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
// Refresh the lists when a live event lands (move / your-turn / match-found).
|
||||
$effect(() => {
|
||||
if (app.lastEvent) void load();
|
||||
});
|
||||
|
||||
const myId = $derived(app.session?.userId ?? '');
|
||||
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 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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header 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}
|
||||
{/snippet}
|
||||
</Header>
|
||||
|
||||
<main class="lobby">
|
||||
<section>
|
||||
<h2>{t('lobby.activeGames')}</h2>
|
||||
{#if active.length === 0}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<style>
|
||||
.lobby {
|
||||
padding: var(--pad);
|
||||
padding-bottom: 84px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.who {
|
||||
font-weight: 600;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { loginEmail, loginGuest, requestEmailCode } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let code = $state('');
|
||||
let stage = $state<'choose' | 'code'>('choose');
|
||||
let busy = $state(false);
|
||||
|
||||
async function sendCode() {
|
||||
if (!email.trim()) return;
|
||||
busy = true;
|
||||
const ok = await requestEmailCode(email.trim());
|
||||
busy = false;
|
||||
if (ok) stage = 'code';
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
busy = true;
|
||||
await loginEmail(email.trim(), code.trim());
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="login">
|
||||
<div class="card">
|
||||
<h1>{t('app.title')}</h1>
|
||||
|
||||
<button class="primary" disabled={busy} onclick={() => loginGuest()}>
|
||||
{t('login.guest')}
|
||||
</button>
|
||||
|
||||
<div class="divider"><span>{t('login.email')}</span></div>
|
||||
|
||||
{#if stage === 'choose'}
|
||||
<input
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
bind:value={email}
|
||||
/>
|
||||
<button class="secondary" disabled={busy || !email.trim()} onclick={sendCode}>
|
||||
{t('login.sendCode')}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">{t('login.codeSent', { email })}</p>
|
||||
<input
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
placeholder={t('login.codePlaceholder')}
|
||||
bind:value={code}
|
||||
/>
|
||||
<button class="secondary" disabled={busy || !code.trim()} onclick={signIn}>
|
||||
{t('login.signIn')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.login {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.card {
|
||||
width: min(94vw, 360px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
}
|
||||
input {
|
||||
padding: 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
button {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import Header from '../components/Header.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { handleError } from '../lib/app.svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { Variant } from '../lib/model';
|
||||
|
||||
const variants: { id: Variant; label: MessageKey }[] = [
|
||||
{ id: 'english', label: 'new.english' },
|
||||
{ id: 'russian', label: 'new.russian' },
|
||||
{ id: 'erudit', label: 'new.erudit' },
|
||||
];
|
||||
|
||||
let searching = $state(false);
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stop() {
|
||||
if (poll) {
|
||||
clearInterval(poll);
|
||||
poll = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function find(v: Variant) {
|
||||
searching = true;
|
||||
try {
|
||||
const r = await gateway.lobbyEnqueue(v);
|
||||
if (r.matched && r.game) {
|
||||
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();
|
||||
if (p.matched && p.game) {
|
||||
stop();
|
||||
navigate(`/game/${p.game.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, 2500);
|
||||
} catch (e) {
|
||||
searching = false;
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--pad);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.variants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.variant {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.searching {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 14px;
|
||||
padding: 48px 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.cancel {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import Header from '../components/Header.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>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--pad);
|
||||
}
|
||||
.name {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.logout {
|
||||
margin-top: 16px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import Header from '../components/Header.svelte';
|
||||
import { app, setLocalePref, setReduceMotion, setTheme } from '../lib/app.svelte';
|
||||
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
|
||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
||||
auto: 'settings.themeAuto',
|
||||
light: 'settings.themeLight',
|
||||
dark: 'settings.themeDark',
|
||||
};
|
||||
const locales: Locale[] = ['en', 'ru'];
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--pad);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.opt {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.opt.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user