efa1d0bd22
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Following the in-game bar, the Connecting indicator now also visually disables the
other proactive (server-sending) controls while offline: chat send + nudge, profile
save / link email|telegram / merge-confirm, friends (redeem, get-code, accept/decline,
unfriend, block, unblock), New Game (auto-match variant + send-invitation) and the
lobby hide ❌. Purely local controls (board/rack/reset, menus, navigation, settings,
copy-code) stay live. Each reads the global connection.online signal; full e2e + check
green.
398 lines
12 KiB
Svelte
398 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import Screen from '../components/Screen.svelte';
|
||
import Menu from '../components/Menu.svelte';
|
||
import TabBar from '../components/TabBar.svelte';
|
||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||
import { connection } from '../lib/connection.svelte';
|
||
import { gateway } from '../lib/gateway';
|
||
import { navigate } from '../lib/router.svelte';
|
||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||
import { resultBadge } from '../lib/result';
|
||
import { getLobby, setLobby } from '../lib/lobbycache';
|
||
import { groupGames } from '../lib/lobbysort';
|
||
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;
|
||
}
|
||
setLobby({ games, invitations, incoming });
|
||
} catch (e) {
|
||
handleError(e);
|
||
}
|
||
}
|
||
|
||
onMount(() => {
|
||
// Render instantly from the cached lists (so the screen does not "draw in" during
|
||
// the back-slide), then refresh in the background.
|
||
const cached = getLobby();
|
||
if (cached) {
|
||
games = cached.games;
|
||
invitations = cached.invitations;
|
||
incoming = cached.incoming;
|
||
}
|
||
void load();
|
||
});
|
||
$effect(() => {
|
||
if (app.lastEvent) void load();
|
||
});
|
||
|
||
const myId = $derived(app.session?.userId ?? '');
|
||
const groups = $derived(groupGames(games, myId));
|
||
|
||
function opponents(g: GameView): string {
|
||
return g.seats
|
||
.filter((s) => s.accountId !== myId)
|
||
.map((s) => s.displayName)
|
||
.join(', ');
|
||
}
|
||
function scoreline(g: GameView): string {
|
||
const me = g.seats.find((s) => s.accountId === myId);
|
||
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
|
||
return `${me?.score ?? 0} : ${opp.join(', ')}`;
|
||
}
|
||
|
||
// Hiding a finished game (Stage 17). The delete action sits behind each finished row and is
|
||
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
|
||
// per-account and irreversible. Only one row is revealed at a time.
|
||
let revealedId = $state<string | null>(null);
|
||
let drag: { id: string; x0: number; y0: number } | null = null;
|
||
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
|
||
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
|
||
let swiped = false;
|
||
|
||
function onRowDown(e: PointerEvent, id: string): void {
|
||
swiped = false;
|
||
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
|
||
drag = { id, x0: e.clientX, y0: e.clientY };
|
||
}
|
||
function onRowUp(e: PointerEvent, finished: boolean): void {
|
||
if (!drag) return;
|
||
const dx = e.clientX - drag.x0;
|
||
const dy = e.clientY - drag.y0;
|
||
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
|
||
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
|
||
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||
swiped = true;
|
||
revealedId = dx < 0 ? drag.id : null;
|
||
}
|
||
drag = null;
|
||
}
|
||
function openGame(g: GameView): void {
|
||
if (swiped) {
|
||
swiped = false;
|
||
return;
|
||
}
|
||
if (revealedId === g.id) {
|
||
revealedId = null;
|
||
return;
|
||
}
|
||
navigate(`/game/${g.id}`);
|
||
}
|
||
function toggleReveal(id: string): void {
|
||
revealedId = revealedId === id ? null : id;
|
||
}
|
||
async function hide(id: string): Promise<void> {
|
||
revealedId = null;
|
||
const prev = games;
|
||
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
|
||
setLobby({ games, invitations, incoming });
|
||
try {
|
||
await gateway.hideGame(id);
|
||
} catch (e) {
|
||
games = prev;
|
||
setLobby({ games, invitations, incoming });
|
||
handleError(e);
|
||
}
|
||
}
|
||
|
||
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_scrabble: 'new.russian',
|
||
erudit: 'new.erudit',
|
||
};
|
||
</script>
|
||
|
||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||
{#snippet menu()}
|
||
<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.yourTurn', list: groups.yourTurn, finished: false }, { h: 'lobby.theirTurn', list: groups.theirTurn, finished: false }, { h: 'lobby.finishedGames', list: groups.finished, finished: true }] as group (group.h)}
|
||
{#if group.list.length}
|
||
<section>
|
||
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
|
||
<div class="list">
|
||
{#each group.list as g (g.id)}
|
||
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
||
{#if group.finished}
|
||
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||
{/if}
|
||
<div class="row">
|
||
<button
|
||
class="open"
|
||
onpointerdown={(e) => onRowDown(e, g.id)}
|
||
onpointerup={(e) => onRowUp(e, group.finished)}
|
||
onclick={() => openGame(g)}
|
||
>
|
||
<span class="info">
|
||
<span class="who">{opponents(g) || '—'}</span>
|
||
<span class="sub">{scoreline(g)}</span>
|
||
</span>
|
||
<span class="emoji">{resultBadge(g, myId).emoji}</span>
|
||
</button>
|
||
{#if group.finished}
|
||
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
||
{:else}
|
||
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
|
||
of the tab order / a11y tree since the .open button already does the same. -->
|
||
<button class="chev" onclick={() => openGame(g)} tabindex={-1} aria-hidden="true">›</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
{/each}
|
||
|
||
{#if !games.length && !invitations.length}
|
||
<p class="empty">{t('lobby.noActive')}</p>
|
||
{/if}
|
||
</div>
|
||
|
||
{#snippet tabbar()}
|
||
<TabBar>
|
||
<button class="tab" onclick={() => navigate('/new')}>
|
||
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||
</button>
|
||
<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.tournaments')}</span>
|
||
</button>
|
||
</TabBar>
|
||
{/snippet}
|
||
</Screen>
|
||
|
||
<style>
|
||
.lobby {
|
||
padding: var(--pad);
|
||
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;
|
||
}
|
||
.invite {
|
||
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);
|
||
user-select: none;
|
||
}
|
||
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
|
||
consecutive rows (Stage 17). */
|
||
.list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
/* Each finished row can slide left to reveal a delete action sitting behind it; the row's
|
||
own opaque background hides that action until revealed (Stage 17). */
|
||
.rowwrap {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.rowwrap + .rowwrap {
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.del {
|
||
position: absolute;
|
||
inset: 0 0 0 auto;
|
||
width: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
background: var(--danger);
|
||
color: #fff;
|
||
font-size: 1.1rem;
|
||
}
|
||
.row {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
background: var(--bg);
|
||
transform: translateX(0);
|
||
transition: transform 0.18s ease;
|
||
}
|
||
.rowwrap.revealed .row {
|
||
transform: translateX(-64px);
|
||
}
|
||
.open {
|
||
flex: 1 1 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
text-align: left;
|
||
padding: 10px 6px;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text);
|
||
user-select: none;
|
||
touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */
|
||
}
|
||
.open:active {
|
||
background: var(--surface-2);
|
||
}
|
||
.kebab {
|
||
flex: 0 0 auto;
|
||
width: 30px;
|
||
padding: 10px 0;
|
||
border: none;
|
||
background: none;
|
||
color: var(--text-muted);
|
||
font-size: 1.4rem;
|
||
line-height: 1;
|
||
}
|
||
.chev {
|
||
flex: 0 0 auto;
|
||
width: 30px;
|
||
padding: 10px 0;
|
||
border: none;
|
||
background: none;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
font-size: 1.4rem;
|
||
line-height: 1;
|
||
}
|
||
.info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
.who {
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.sub {
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
}
|
||
.emoji {
|
||
font-size: 1.8rem;
|
||
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>
|