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,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>
|
||||
Reference in New Issue
Block a user