Stage 8: UI social/account/history surfaces
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:
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user