Stage 8: UI social/account/history surfaces
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s

Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
This commit is contained in:
Ilia Denisov
2026-06-03 19:47:40 +02:00
parent 539e24fba1
commit d733ce3119
114 changed files with 8210 additions and 149 deletions
+243
View File
@@ -0,0 +1,243 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte';
import type { AccountRef, FriendCode } from '../lib/model';
let friends = $state<AccountRef[]>([]);
let incoming = $state<AccountRef[]>([]);
let blocked = $state<AccountRef[]>([]);
let code = $state<FriendCode | null>(null);
let redeemInput = $state('');
async function load() {
try {
[friends, incoming, blocked] = await Promise.all([
gateway.friendsList(),
gateway.friendsIncoming(),
gateway.blocksList(),
]);
} catch (e) {
handleError(e);
}
}
onMount(() => {
if (!app.profile?.isGuest) void load();
});
async function act(fn: () => Promise<unknown>) {
try {
await fn();
await load();
void refreshNotifications();
} catch (e) {
handleError(e);
}
}
const respond = (id: string, accept: boolean) => act(() => gateway.friendRespond(id, accept));
const remove = (id: string) => act(() => gateway.unfriend(id));
const blockUser = (id: string) => act(() => gateway.block(id));
const unblock = (id: string) => act(() => gateway.unblock(id));
async function getCode() {
try {
code = await gateway.friendCodeIssue();
} catch (e) {
handleError(e);
}
}
async function redeem() {
const c = redeemInput.trim();
if (!c) return;
try {
const friend = await gateway.friendCodeRedeem(c);
redeemInput = '';
showToast(t('friends.added', { name: friend.displayName }));
await load();
} catch (e) {
handleError(e);
}
}
function codeTime(unix: number): string {
return new Date(unix * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
</script>
<Screen title={t('friends.title')} back="/">
<div class="page">
{#if app.profile?.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<section>
<h3>{t('friends.add')}</h3>
<div class="addrow">
<input
class="codein"
bind:value={redeemInput}
placeholder={t('friends.codePlaceholder')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
</div>
{#if code}
<div class="code" data-testid="friend-code">
<span class="codeval">{code.code}</span>
<span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
</div>
{:else}
<button class="link" onclick={getCode}>{t('friends.getCode')}</button>
{/if}
</section>
{#if incoming.length}
<section>
<h3>{t('friends.incoming')}</h3>
{#each incoming as r (r.accountId)}
<div class="item">
<span class="who">{r.displayName}</span>
<span class="acts">
<button class="btn" onclick={() => respond(r.accountId, true)}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)}>{t('friends.decline')}</button>
</span>
</div>
{/each}
</section>
{/if}
<section>
<h3>{t('friends.yours')}</h3>
{#if friends.length}
{#each friends as f (f.accountId)}
<div class="item">
<span class="who">{f.displayName}</span>
<span class="acts">
<button class="ghost" onclick={() => remove(f.accountId)}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)}>{t('friends.block')}</button>
</span>
</div>
{/each}
{:else}
<p class="muted">{t('friends.none')}</p>
{/if}
</section>
{#if blocked.length}
<section>
<h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)}
<div class="item">
<span class="who">{b.displayName}</span>
<button class="ghost" onclick={() => unblock(b.accountId)}>{t('friends.unblock')}</button>
</div>
{/each}
</section>
{/if}
{/if}
</div>
</Screen>
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 22px;
}
h3 {
margin: 0 0 10px;
font-size: 0.95rem;
color: var(--text-muted);
}
.muted {
color: var(--text-muted);
margin: 0;
}
.addrow {
display: flex;
gap: 8px;
}
.codein {
flex: 1;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
letter-spacing: 0.3em;
font-size: 1.1rem;
}
.code {
margin-top: 10px;
padding: 12px 14px;
border: 1px dashed var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 4px;
}
.codeval {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 0.3em;
}
.codehint {
font-size: 0.8rem;
color: var(--text-muted);
}
.link {
margin-top: 10px;
background: none;
border: none;
color: var(--accent);
padding: 4px 0;
text-align: left;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
margin-bottom: 8px;
}
.who {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ghost.danger {
color: var(--danger, #c0392b);
}
</style>
+91 -8
View File
@@ -6,15 +6,23 @@
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 { t, type MessageKey } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import type { GameView } from '../lib/model';
import type { AccountRef, GameView, Invitation } from '../lib/model';
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
let incoming = $state<AccountRef[]>([]);
const guest = $derived(app.profile?.isGuest ?? true);
async function load() {
try {
games = (await gateway.gamesList()).games;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
app.notifications = invitations.length + incoming.length;
}
} catch (e) {
handleError(e);
}
@@ -42,18 +50,73 @@
}
const menuItems = $derived([
...(guest ? [] : [{ label: t('lobby.friends'), onclick: () => navigate('/friends'), badge: incoming.length }]),
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
{ label: t('lobby.settings'), onclick: () => navigate('/settings') },
{ label: t('lobby.about'), onclick: () => navigate('/about') },
]);
async function acceptInvite(inv: Invitation) {
try {
const r = await gateway.invitationAccept(inv.id);
if (r.gameId) navigate(`/game/${r.gameId}`);
else await load();
} catch (e) {
handleError(e);
}
}
const declineInvite = (inv: Invitation) => act(() => gateway.invitationDecline(inv.id));
const cancelInvite = (inv: Invitation) => act(() => gateway.invitationCancel(inv.id));
async function act(fn: () => Promise<unknown>) {
try {
await fn();
await load();
} catch (e) {
handleError(e);
}
}
const variantKey: Record<string, MessageKey> = {
english: 'new.english',
russian: 'new.russian',
erudit: 'new.erudit',
};
</script>
<Screen title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()}
<Menu items={menuItems} />
<Menu items={menuItems} badge={app.notifications} />
{/snippet}
<div class="lobby">
{#if invitations.length}
<section>
<h2>{t('lobby.invitations')}</h2>
{#each invitations as inv (inv.id)}
<div class="invite">
<span class="emoji">💌</span>
<span class="info">
{#if inv.inviter.accountId === myId}
<span class="who">{t('invitations.with', { names: inv.invitees.map((i) => i.displayName).join(', ') })}</span>
<span class="sub">{t('invitations.waiting')}</span>
{:else}
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
<span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span>
{/if}
</span>
<span class="acts">
{#if inv.inviter.accountId === myId}
<button class="ghost" onclick={() => cancelInvite(inv)}>{t('invitations.cancel')}</button>
{:else}
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button>
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button>
{/if}
</span>
</div>
{/each}
</section>
{/if}
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
{#if group.list.length}
<section>
@@ -72,7 +135,7 @@
{/if}
{/each}
{#if !active.length && !finished.length}
{#if !active.length && !finished.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
</div>
@@ -82,11 +145,11 @@
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">⚔️</span><span class="lbl">{t('lobby.tournaments')}</span>
<button class="tab" onclick={() => navigate('/stats')}>
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">🧮</span><span class="lbl">{t('lobby.stats')}</span>
<span class="sq">🏆</span><span class="lbl">{t('lobby.tournaments')}</span>
</button>
</TabBar>
{/snippet}
@@ -111,7 +174,8 @@
font-size: 0.9rem;
margin: 0;
}
.row {
.row,
.invite {
display: flex;
align-items: center;
justify-content: space-between;
@@ -147,4 +211,23 @@
line-height: 1;
flex: 0 0 auto;
}
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
</style>
+162 -10
View File
@@ -1,18 +1,28 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { handleError } from '../lib/app.svelte';
import { app, handleError, showToast } 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';
import type { AccountRef, 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' },
];
const timeouts = [
{ secs: 300, key: 'time.minutes' as MessageKey, n: 5 },
{ secs: 1800, key: 'time.minutes' as MessageKey, n: 30 },
{ secs: 3600, key: 'time.hours' as MessageKey, n: 1 },
{ secs: 86400, key: 'time.hours' as MessageKey, n: 24 },
];
const guest = $derived(app.profile?.isGuest ?? true);
let mode = $state<'auto' | 'friends'>('auto');
// --- auto-match ---
let searching = $state(false);
let poll: ReturnType<typeof setInterval> | null = null;
@@ -48,6 +58,43 @@
}
}
// --- friend game ---
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
let inviteVariant = $state<Variant>('english');
let timeoutSecs = $state(86400);
let hints = $state(1);
onMount(async () => {
if (guest) return;
try {
friends = await gateway.friendsList();
} catch (e) {
handleError(e);
}
});
function toggle(id: string) {
selected = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id];
}
async function sendInvite() {
if (selected.length === 0 || selected.length > 3) return;
try {
await gateway.invitationCreate(selected, {
variant: inviteVariant,
turnTimeoutSecs: timeoutSecs,
hintsAllowed: hints > 0,
hintsPerPlayer: hints,
dropoutTiles: 'remove',
});
showToast(t('new.invited'));
navigate('/');
} catch (e) {
handleError(e);
}
}
onDestroy(stop);
</script>
@@ -60,12 +107,60 @@
<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 !guest}
<div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
<button class="opt" class:active={mode === 'friends'} onclick={() => (mode = 'friends')}>{t('new.withFriends')}</button>
</div>
{/if}
{#if mode === 'auto'}
<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>
{:else}
{#if friends.length === 0}
<p class="subtitle">{t('new.noFriends')}</p>
{:else}
<h3>{t('new.pickFriends')}</h3>
<div class="friends">
{#each friends as f (f.accountId)}
<label class="friend">
<input type="checkbox" checked={selected.includes(f.accountId)} onchange={() => toggle(f.accountId)} />
<span>{f.displayName}</span>
</label>
{/each}
</div>
<h3>{t('new.title')}</h3>
<div class="seg">
{#each variants as v (v.id)}
<button class="opt" class:active={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>{t(v.label)}</button>
{/each}
</div>
<h3>{t('new.moveTime')}</h3>
<div class="seg">
{#each timeouts as to (to.secs)}
<button class="opt" class:active={timeoutSecs === to.secs} onclick={() => (timeoutSecs = to.secs)}>
{t(to.key, { n: to.n })}
</button>
{/each}
</div>
<h3>{t('new.hintsPerPlayer')}</h3>
<div class="seg">
{#each [0, 1, 2] as h (h)}
<button class="opt" class:active={hints === h} onclick={() => (hints = h)}>{h}</button>
{/each}
</div>
<button class="invite" disabled={selected.length === 0} onclick={sendInvite}>{t('new.invite')}</button>
{/if}
{/if}
{/if}
</div>
</Screen>
@@ -73,15 +168,23 @@
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 14px;
}
.subtitle {
color: var(--text-muted);
margin: 0;
}
h3 {
margin: 6px 0 0;
font-size: 0.9rem;
color: var(--text-muted);
}
.variants {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.variant {
padding: 16px;
@@ -93,6 +196,55 @@
font-weight: 600;
user-select: none;
}
.seg {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.modes {
margin-bottom: 4px;
}
.opt {
flex: 1;
min-width: 64px;
padding: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
user-select: none;
}
.opt.active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.friends {
display: flex;
flex-direction: column;
gap: 6px;
}
.friend {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
}
.invite {
margin-top: 8px;
padding: 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius);
font-weight: 600;
}
.invite:disabled {
opacity: 0.5;
}
.searching {
display: grid;
place-items: center;
+218 -16
View File
@@ -1,23 +1,141 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import { app, logout } from '../lib/app.svelte';
import { app, handleError, logout, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte';
import type { ProfileUpdate } from '../lib/model';
let editing = $state(false);
let form = $state<ProfileUpdate>(blankForm());
let emailInput = $state('');
let codeInput = $state('');
let emailSent = $state(false);
function blankForm(): ProfileUpdate {
const p = app.profile;
return {
displayName: p?.displayName ?? '',
preferredLanguage: p?.preferredLanguage ?? 'en',
timeZone: p?.timeZone ?? 'UTC',
awayStart: p?.awayStart ?? '00:00',
awayEnd: p?.awayEnd ?? '07:00',
blockChat: p?.blockChat ?? false,
blockFriendRequests: p?.blockFriendRequests ?? false,
};
}
function startEdit() {
form = blankForm();
editing = true;
}
async function save() {
try {
app.profile = await gateway.profileUpdate(form);
editing = false;
showToast(t('profile.saved'));
} catch (e) {
handleError(e);
}
}
async function requestEmail() {
const email = emailInput.trim();
if (!email) return;
try {
await gateway.emailBindRequest(email);
emailSent = true;
showToast(t('profile.emailSent', { email }));
} catch (e) {
handleError(e);
}
}
async function confirmEmail() {
try {
app.profile = await gateway.emailBindConfirm(emailInput.trim(), codeInput.trim());
emailSent = false;
emailInput = '';
codeInput = '';
showToast(t('profile.emailBound'));
} catch (e) {
handleError(e);
}
}
</script>
<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>
{@const p = app.profile}
<div class="name">{p.displayName}</div>
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
{#if editing}
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
<label>
<span>{t('profile.displayName')}</span>
<input bind:value={form.displayName} maxlength="64" />
</label>
<label>
<span>{t('profile.timezone')}</span>
<input bind:value={form.timeZone} />
</label>
<fieldset class="away">
<legend>{t('profile.awayWindow')}</legend>
<div class="times">
<label><span>{t('profile.from')}</span><input type="time" bind:value={form.awayStart} /></label>
<label><span>{t('profile.to')}</span><input type="time" bind:value={form.awayEnd} /></label>
</div>
<p class="muted">{t('profile.awayHint')}</p>
</fieldset>
<label class="check">
<input type="checkbox" bind:checked={form.blockChat} />
<span>{t('profile.blockChat')}</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={form.blockFriendRequests} />
<span>{t('profile.blockFriendRequests')}</span>
</label>
<div class="formacts">
<button type="submit" class="btn">{t('common.save')}</button>
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
</div>
</form>
{:else}
<dl>
<dt>{t('profile.language')}</dt>
<dd>{p.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt>
<dd>{p.timeZone}</dd>
<dt>{t('profile.awayWindow')}</dt>
<dd>{p.awayStart}{p.awayEnd}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{p.hintBalance}</dd>
</dl>
{#if p.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<button class="btn" onclick={startEdit}>{t('profile.edit')}</button>
<section class="emailbox">
<h3>{t('profile.bindEmail')}</h3>
{#if !emailSent}
<div class="addrow">
<input bind:value={emailInput} placeholder={t('login.emailPlaceholder')} type="email" />
<button class="ghost" onclick={requestEmail}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
</section>
{/if}
{/if}
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</div>
@@ -26,14 +144,16 @@
<style>
.page {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 16px;
}
.name {
font-size: 1.4rem;
font-weight: 700;
}
.badge {
display: inline-block;
margin-top: 4px;
align-self: flex-start;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-2);
@@ -44,7 +164,7 @@
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin: 20px 0;
margin: 0;
}
dt {
color: var(--text-muted);
@@ -56,9 +176,91 @@
.muted {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
}
.edit {
display: flex;
flex-direction: column;
gap: 14px;
}
.edit label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
color: var(--text-muted);
}
.edit input:not([type]),
.edit input[type='time'] {
padding: 9px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.away {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 0;
}
.away legend {
color: var(--text-muted);
font-size: 0.9rem;
padding: 0 4px;
}
.times {
display: flex;
gap: 12px;
}
.times label {
flex: 1;
}
.check {
flex-direction: row !important;
align-items: center;
gap: 10px !important;
color: var(--text) !important;
}
.formacts {
display: flex;
gap: 10px;
}
.emailbox h3 {
margin: 0 0 8px;
font-size: 0.95rem;
color: var(--text-muted);
}
.addrow {
display: flex;
gap: 8px;
}
.addrow input {
flex: 1;
padding: 9px 11px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.btn {
align-self: flex-start;
padding: 9px 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.ghost {
padding: 9px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.logout {
margin-top: 16px;
margin-top: 8px;
align-self: flex-start;
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import { gamesPlayed, winRate } from '../lib/stats';
import type { Stats } from '../lib/model';
let stats = $state<Stats | null>(null);
onMount(async () => {
if (app.profile?.isGuest) return;
try {
stats = await gateway.statsGet();
} catch (e) {
handleError(e);
}
});
const cards = $derived<{ key: MessageKey; value: string | number }[]>(
stats
? [
{ key: 'stats.wins', value: stats.wins },
{ key: 'stats.losses', value: stats.losses },
{ key: 'stats.draws', value: stats.draws },
{ key: 'stats.played', value: gamesPlayed(stats) },
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
{ key: 'stats.maxGame', value: stats.maxGamePoints },
{ key: 'stats.maxWord', value: stats.maxWordPoints },
]
: [],
);
</script>
<Screen title={t('stats.title')} back="/">
<div class="page">
{#if app.profile?.isGuest}
<p class="muted">{t('stats.guestHint')}</p>
{:else if stats}
<div class="grid">
{#each cards as c (c.key)}
<div class="card">
<span class="num">{c.value}</span>
<span class="lbl">{t(c.key)}</span>
</div>
{/each}
</div>
{/if}
</div>
</Screen>
<style>
.page {
padding: var(--pad);
}
.muted {
color: var(--text-muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 18px 16px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
}
.num {
font-size: 1.7rem;
font-weight: 700;
}
.lbl {
color: var(--text-muted);
font-size: 0.85rem;
}
</style>