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
+3 -2
View File
@@ -257,6 +257,7 @@ export const en = {
'invitations.with': 'With {names}', 'invitations.with': 'With {names}',
'invitations.accept': 'Accept', 'invitations.accept': 'Accept',
'invitations.decline': 'Decline', 'invitations.decline': 'Decline',
'invitations.declineConfirm': 'Decline invitation?',
'invitations.cancel': 'Cancel', 'invitations.cancel': 'Cancel',
'invitations.waiting': 'Waiting for replies', 'invitations.waiting': 'Waiting for replies',
@@ -264,10 +265,10 @@ export const en = {
'new.withFriends': 'Play with friends', 'new.withFriends': 'Play with friends',
'new.pickFriends': 'Choose who to invite', 'new.pickFriends': 'Choose who to invite',
'new.searchFriends': 'Search friends', 'new.searchFriends': 'Search friends',
'new.gameType': 'Game type', 'new.gameType': 'Variant',
'new.invite': 'Send invitation', 'new.invite': 'Send invitation',
'new.moveTime': 'Move time', 'new.moveTime': 'Move time',
'new.hintsPerPlayer': 'Hints per player', 'new.hintsPerPlayer': 'Hints',
'new.multipleWordsPerTurn': 'Multiple words per turn', 'new.multipleWordsPerTurn': 'Multiple words per turn',
'new.start': 'Start game', 'new.start': 'Start game',
'new.invited': 'Invitation sent.', 'new.invited': 'Invitation sent.',
+3 -2
View File
@@ -258,6 +258,7 @@ export const ru: Record<MessageKey, string> = {
'invitations.with': 'С {names}', 'invitations.with': 'С {names}',
'invitations.accept': 'Принять', 'invitations.accept': 'Принять',
'invitations.decline': 'Отклонить', 'invitations.decline': 'Отклонить',
'invitations.declineConfirm': 'Отклонить приглашение?',
'invitations.cancel': 'Отменить', 'invitations.cancel': 'Отменить',
'invitations.waiting': 'Ожидаем ответы', 'invitations.waiting': 'Ожидаем ответы',
@@ -265,10 +266,10 @@ export const ru: Record<MessageKey, string> = {
'new.withFriends': 'Игра с друзьями', 'new.withFriends': 'Игра с друзьями',
'new.pickFriends': 'Кого пригласить', 'new.pickFriends': 'Кого пригласить',
'new.searchFriends': 'Поиск друзей', 'new.searchFriends': 'Поиск друзей',
'new.gameType': 'Тип игры', 'new.gameType': 'Вариант',
'new.invite': 'Отправить приглашение', 'new.invite': 'Отправить приглашение',
'new.moveTime': 'Время на ход', 'new.moveTime': 'Время на ход',
'new.hintsPerPlayer': 'Подсказок на игрока', 'new.hintsPerPlayer': 'Подсказки',
'new.multipleWordsPerTurn': 'Несколько слов за ход', 'new.multipleWordsPerTurn': 'Несколько слов за ход',
'new.start': 'Начать игру', 'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.', 'new.invited': 'Приглашение отправлено.',
+83 -20
View File
@@ -3,17 +3,19 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte'; 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 { resultBadge } from '../lib/result';
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; import { preloadGames } from '../lib/preload';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
let games = $state<GameView[]>([]); let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]); let invitations = $state<Invitation[]>([]);
@@ -209,11 +211,14 @@
} }
} }
const variantKey: Record<string, MessageKey> = { // The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
scrabble_en: 'new.english', // in-game resign confirmation) rather than declining on the first tap.
scrabble_ru: 'new.russian', let declineTarget = $state<Invitation | null>(null);
erudit_ru: 'new.erudit', function confirmDecline() {
}; const inv = declineTarget;
declineTarget = null;
if (inv) declineInvite(inv);
}
</script> </script>
<Screen title={app.profile?.displayName ?? t('app.title')}> <Screen title={app.profile?.displayName ?? t('app.title')}>
@@ -230,16 +235,23 @@
<span class="sub">{t('invitations.waiting')}</span> <span class="sub">{t('invitations.waiting')}</span>
{:else} {:else}
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span> <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}
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if} {#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
</span> </span>
<span class="acts"> <span class="acts">
{#if inv.inviter.accountId === myId} {#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} {:else}
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button> <button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}></button>
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button> <button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={t('invitations.decline')}></button>
{/if} {/if}
</span> </span>
</div> </div>
@@ -308,6 +320,15 @@
{/if} {/if}
</div> </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()} {#snippet tabbar()}
<TabBar> <TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}> <button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
@@ -365,6 +386,40 @@
border-radius: var(--radius); border-radius: var(--radius);
user-select: none; 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 /* Game rows are a compact, flat list: no per-card frame, a hairline divider between
consecutive rows. */ consecutive rows. */
.list { .list {
@@ -516,23 +571,31 @@
opacity: 0; opacity: 0;
} }
} }
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
.acts { .acts {
display: flex; display: flex;
gap: 8px; flex-direction: column;
gap: 10px;
flex: 0 0 auto; flex: 0 0 auto;
justify-content: center;
} }
.btn { /* Decline-confirmation modal (mirrors the in-game resign confirm). */
padding: 8px 12px; .confirm-row {
border: 1px solid var(--accent); display: flex;
background: var(--accent); gap: 8px;
color: var(--accent-text); }
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
color: var(--text); 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> </style>
+12 -3
View File
@@ -67,9 +67,12 @@
let friends = $state<AccountRef[]>([]); let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]); let selected = $state<string[]>([]);
let friendFilter = $state(''); let friendFilter = $state('');
// No default game type yet — the player must pick one (a smarter default from play // A lone offered variant is pre-selected and its picker disabled (nothing else to choose);
// history / language would be a future refinement). '' renders the disabled placeholder option. // with several, the player picks. '' renders the disabled placeholder option.
let inviteVariant = $state<Variant | ''>(''); let inviteVariant = $state<Variant | ''>('');
$effect(() => {
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
});
let timeoutSecs = $state(86400); let timeoutSecs = $state(86400);
let hints = $state(1); let hints = $state(1);
@@ -152,6 +155,7 @@
<input type="checkbox" bind:checked={multipleWords} /> <input type="checkbox" bind:checked={multipleWords} />
</label> </label>
{/if} {/if}
<div class="grow"></div>
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if} {#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button <button
@@ -179,7 +183,7 @@
<div class="settings-row"> <div class="settings-row">
<label class="field"> <label class="field">
<span>{t('new.gameType')}</span> <span>{t('new.gameType')}</span>
<select bind:value={inviteVariant} class:placeholder={!inviteVariant}> <select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}>
<option value="" disabled></option> <option value="" disabled></option>
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each} {#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
</select> </select>
@@ -387,6 +391,11 @@
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
} }
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
mirroring the friend-game invite button pinned by the .fg scroll area. */
.grow {
flex: 1;
}
.invite { .invite {
flex: 0 0 auto; flex: 0 0 auto;
padding: 14px; padding: 14px;