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