diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte index 5d7906c..7449b5e 100644 --- a/ui/src/components/Modal.svelte +++ b/ui/src/components/Modal.svelte @@ -36,7 +36,10 @@ box-shadow: var(--shadow); padding: var(--pad); width: min(94vw, 420px); + /* dvh tracks the dynamic viewport, so the sheet shrinks above an open mobile + keyboard instead of being scrolled off the top (vh fallback first). */ max-height: 86vh; + max-height: 86dvh; overflow: auto; } h2 { diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index ad9f055..638f6b5 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -56,7 +56,10 @@ display: flex; flex-direction: column; gap: 10px; + /* dvh so the chat shrinks with an open keyboard, keeping the start of the + conversation on screen instead of pushed above the fold (vh fallback). */ height: 56vh; + height: 56dvh; } .list { flex: 1; diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 9a690bb..2e7093b 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -40,6 +40,9 @@ display: flex; gap: 5px; align-items: center; + /* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the + footer the same size as during play — no layout jump between states. */ + min-height: min(12.5vw, 46px); } .tile { position: relative; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1a260d3..9296c5e 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -196,6 +196,8 @@ export const en = { 'new.auto': 'Quick match', 'new.withFriends': 'Play with friends', 'new.pickFriends': 'Choose who to invite', + 'new.searchFriends': 'Search friends', + 'new.gameType': 'Game type', 'new.invite': 'Send invitation', 'new.moveTime': 'Move time', 'new.hintsPerPlayer': 'Hints per player', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 97d9893..b992967 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -197,6 +197,8 @@ export const ru: Record = { 'new.auto': 'Быстрая игра', 'new.withFriends': 'Игра с друзьями', 'new.pickFriends': 'Кого пригласить', + 'new.searchFriends': 'Поиск друзей', + 'new.gameType': 'Тип игры', 'new.invite': 'Отправить приглашение', 'new.moveTime': 'Время на ход', 'new.hintsPerPlayer': 'Подсказок на игрока', diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 8db6cad..f1fe527 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -61,10 +61,17 @@ // --- friend game --- let friends = $state([]); let selected = $state([]); + let friendFilter = $state(''); let inviteVariant = $state('english'); let timeoutSecs = $state(86400); let hints = $state(1); + const filteredFriends = $derived( + friendFilter.trim() + ? friends.filter((f) => f.displayName.toLowerCase().includes(friendFilter.trim().toLowerCase())) + : friends, + ); + onMount(async () => { if (guest) return; try { @@ -121,45 +128,45 @@ {/each} + {:else if friends.length === 0} +

{t('new.noFriends')}

{:else} - {#if friends.length === 0} -

{t('new.noFriends')}

- {:else} -

{t('new.pickFriends')}

-
- {#each friends as f (f.accountId)} +
+
+ {t('new.pickFriends')} ({selected.length}) + +
+
+ {#each filteredFriends as f (f.accountId)} {/each} + {#if filteredFriends.length === 0}

{/if}
- -

{t('new.title')}

-
- {#each variants as v (v.id)} - - {/each} +
+ + +
- -

{t('new.moveTime')}

-
- {#each timeouts as to (to.secs)} - - {/each} -
- -

{t('new.hintsPerPlayer')}

-
- {#each [0, 1, 2] as h (h)} - - {/each} -
- - {/if} +
{/if} {/if}
@@ -171,16 +178,13 @@ display: flex; flex-direction: column; gap: 14px; + height: 100%; + box-sizing: border-box; } .subtitle { color: var(--text-muted); margin: 0; } - h3 { - margin: 6px 0 0; - font-size: 0.9rem; - color: var(--text-muted); - } .variants { display: flex; flex-direction: column; @@ -219,7 +223,34 @@ color: var(--accent-text); border-color: var(--accent); } - .friends { + .fg { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + } + .picked { + display: flex; + flex-direction: column; + gap: 8px; + } + .ftitle { + font-size: 0.9rem; + color: var(--text-muted); + } + .search { + min-width: 0; + padding: 9px 11px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + } + .friends-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; display: flex; flex-direction: column; gap: 6px; @@ -233,8 +264,37 @@ background: var(--surface); border-radius: var(--radius-sm); } + .settings-row { + display: flex; + gap: 8px; + } + .field { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + .field span { + font-size: 0.78rem; + color: var(--text-muted); + } + .field select { + width: 100%; + box-sizing: border-box; + min-width: 0; + padding: 9px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + } + .muted { + color: var(--text-muted); + margin: 0; + } .invite { - margin-top: 8px; + flex: 0 0 auto; padding: 14px; border: 1px solid var(--accent); background: var(--accent); diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 7be02d6..2a01ad0 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -5,8 +5,6 @@ import { t } from '../lib/i18n/index.svelte'; import { awayDurationOk, - awayHours, - awayMinutes, browserOffset, isOffsetZone, timezoneOffsets, @@ -17,10 +15,8 @@ let editing = $state(false); let dn = $state(''); let tz = $state('+00:00'); - let startH = $state('00'); - let startM = $state('00'); - let endH = $state('07'); - let endM = $state('00'); + let awayStart = $state('00:00'); + let awayEnd = $state('07:00'); let blockChat = $state(false); let blockFriendRequests = $state(false); let emailInput = $state(''); @@ -31,25 +27,21 @@ const b = browserOffset(); return timezoneOffsets.includes(b) ? b : '+00:00'; } - function splitTime(hhmm: string): [string, string] { - const m = /^(\d{2}):(\d{2})$/.exec(hhmm); - if (!m) return ['00', '00']; - return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00']; + function clockOr(value: string, fallback: string): string { + return /^\d{2}:\d{2}$/.test(value) ? value : fallback; } function startEdit() { const p = app.profile!; dn = p.displayName; tz = isOffsetZone(p.timeZone) && timezoneOffsets.includes(p.timeZone) ? p.timeZone : defaultTz(); - [startH, startM] = splitTime(p.awayStart); - [endH, endM] = splitTime(p.awayEnd); + awayStart = clockOr(p.awayStart, '00:00'); + awayEnd = clockOr(p.awayEnd, '07:00'); blockChat = p.blockChat; blockFriendRequests = p.blockFriendRequests; editing = true; } - const awayStart = $derived(`${startH}:${startM}`); - const awayEnd = $derived(`${endH}:${endM}`); const nameOk = $derived(validDisplayName(dn)); const awayOk = $derived(awayDurationOk(awayStart, awayEnd)); const formValid = $derived(nameOk && awayOk); @@ -120,16 +112,8 @@
{t('profile.awayWindow')}
- {t('profile.from')} - - : - -
-
- {t('profile.to')} - - : - + +

{t('profile.awayHint')}

@@ -271,17 +255,22 @@ } .times { display: flex; - align-items: center; - gap: 8px; + gap: 12px; + } + .times label { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + } + .times input { + width: 100%; + box-sizing: border-box; } .tlabel { - min-width: 2.5em; color: var(--text-muted); font-size: 0.85rem; } - .colon { - font-weight: 700; - } .check { flex-direction: row !important; align-items: center;