feat(ui): lobby invitation card redesign + new-game tweaks

- Lobby friend-invitation card: icon-only checkmark/cross actions stacked in a
  min-width right column; the middle column (From <name> + flag + variant rules,
  like New Game) grows and wraps. The cross now opens a decline-confirmation modal
  (mirroring the in-game resign confirm) instead of declining on first tap.
- New Game with a friend: a lone offered variant is pre-selected and its picker
  disabled (nothing else to choose); relabel 'Тип игры' -> 'Вариант' and
  'Подсказок на игрока' -> 'Подсказки'.
- Quick game: pin the Start button to the bottom of the screen, mirroring the
  friend-game Send-invitation button.
This commit is contained in:
Ilia Denisov
2026-06-22 09:11:36 +02:00
parent 48b06f4594
commit 8a06fbc3c7
4 changed files with 101 additions and 27 deletions
+83 -20
View File
@@ -3,17 +3,19 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } 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 { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
@@ -209,11 +211,14 @@
}
}
const variantKey: Record<string, MessageKey> = {
scrabble_en: 'new.english',
scrabble_ru: 'new.russian',
erudit_ru: 'new.erudit',
};
// The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
// in-game resign confirmation) rather than declining on the first tap.
let declineTarget = $state<Invitation | null>(null);
function confirmDecline() {
const inv = declineTarget;
declineTarget = null;
if (inv) declineInvite(inv);
}
</script>
<Screen title={app.profile?.displayName ?? t('app.title')}>
@@ -230,16 +235,23 @@
<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>
<span class="vrow">
{#if VARIANT_FLAG[inv.variant]}
<span class="vflag">{VARIANT_FLAG[inv.variant]}</span>
{:else}
<img class="vflag-img" src="flag-ussr.svg" alt="" />
{/if}
<span class="sub">{t(VARIANT_RULES[inv.variant])}</span>
</span>
{/if}
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
</span>
<span class="acts">
{#if inv.inviter.accountId === myId}
<button class="ghost" onclick={() => cancelInvite(inv)}>{t('invitations.cancel')}</button>
<button class="iconbtn" onclick={() => cancelInvite(inv)} aria-label={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>
<button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}></button>
<button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={t('invitations.decline')}></button>
{/if}
</span>
</div>
@@ -308,6 +320,15 @@
{/if}
</div>
{#if declineTarget}
<Modal title={t('invitations.declineConfirm')} onclose={() => (declineTarget = null)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (declineTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmDecline} disabled={!connection.online}>{t('invitations.decline')}</button>
</div>
</Modal>
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
@@ -365,6 +386,40 @@
border-radius: var(--radius);
user-select: none;
}
/* The middle column grows; the envelope and the action column stay at their natural width. */
.invite .info {
flex: 1;
}
/* The variant row in an invitation: flag + the rules summary (mirrors NewGame). */
.vrow {
display: flex;
align-items: baseline;
gap: 6px;
}
.vflag {
font-size: 1.1rem;
line-height: 1;
}
.vflag-img {
width: 1.3rem;
height: auto;
border-radius: 2px;
align-self: center;
}
/* Borderless icon action (✅ / ❌), like the in-game .hicon buttons. */
.iconbtn {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.iconbtn:active {
background: var(--bg-elev);
}
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
consecutive rows. */
.list {
@@ -516,23 +571,31 @@
opacity: 0;
}
}
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
.acts {
display: flex;
gap: 8px;
flex-direction: column;
gap: 10px;
flex: 0 0 auto;
justify-content: center;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
/* Decline-confirmation modal (mirrors the in-game resign confirm). */
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
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);
font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
}
</style>