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:
+32
-1
@@ -16,6 +16,7 @@
|
||||
import { replay } from '../lib/board';
|
||||
import { alphabet, centre, premiumGrid } from '../lib/premiums';
|
||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import {
|
||||
BLANK,
|
||||
newPlacement,
|
||||
@@ -369,10 +370,35 @@
|
||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
||||
}
|
||||
|
||||
async function exportGcg() {
|
||||
try {
|
||||
await shareOrDownloadGcg(await gateway.exportGcg(id));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function addFriend(accountId: string) {
|
||||
try {
|
||||
await gateway.friendRequest(accountId);
|
||||
showToast(t('friends.requestSent'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
const opponents = $derived(
|
||||
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
|
||||
);
|
||||
|
||||
const menuItems = $derived([
|
||||
{ label: t('game.history'), onclick: () => (historyOpen = true) },
|
||||
{ label: t('game.chat'), onclick: openChat },
|
||||
{ label: t('game.checkWord'), onclick: openCheck },
|
||||
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
|
||||
...(!app.profile?.isGuest
|
||||
? opponents.map((s) => ({ label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }))
|
||||
: []),
|
||||
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
|
||||
]);
|
||||
</script>
|
||||
@@ -400,7 +426,7 @@
|
||||
<li>
|
||||
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
||||
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
|
||||
<span class="hs">{m.score}</span>
|
||||
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if moves.length === 0}<li class="hempty">—</li>{/if}
|
||||
@@ -628,6 +654,11 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ht {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.hempty {
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
|
||||
Reference in New Issue
Block a user