Stage 8 polish: iPhone refinements (keyboard, native pickers, compact invite)
Tests · UI / test (push) Successful in 17s
Tests · UI / test (push) Successful in 17s
Second owner-review pass (iPhone simulator): - Chat (and the modal) are sized in dvh so they shrink above the software keyboard, keeping the start of the conversation on screen instead of pushed off the top. - The profile away window returns to a native <input type="time" step="600"> (the iOS wheel with 10-minute steps) instead of separate dropdowns; the timezone stays a native offset <select>. - A finished game reserves the rack's height (min-height) so the footer no longer collapses when the final rack is empty — no layout jump versus an active game. - New-game "play with friends" is made compact: a searchable, bounded-scroll friend list, the game-type / move-time / hints controls as native selects in one row (labels above), and Send invitation pinned at the bottom — it scales to many friends.
This commit is contained in:
@@ -36,7 +36,10 @@
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
padding: var(--pad);
|
padding: var(--pad);
|
||||||
width: min(94vw, 420px);
|
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: 86vh;
|
||||||
|
max-height: 86dvh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
|
|||||||
@@ -56,7 +56,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
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: 56vh;
|
||||||
|
height: 56dvh;
|
||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
align-items: center;
|
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 {
|
.tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export const en = {
|
|||||||
'new.auto': 'Quick match',
|
'new.auto': 'Quick match',
|
||||||
'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.gameType': 'Game type',
|
||||||
'new.invite': 'Send invitation',
|
'new.invite': 'Send invitation',
|
||||||
'new.moveTime': 'Move time',
|
'new.moveTime': 'Move time',
|
||||||
'new.hintsPerPlayer': 'Hints per player',
|
'new.hintsPerPlayer': 'Hints per player',
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.auto': 'Быстрая игра',
|
'new.auto': 'Быстрая игра',
|
||||||
'new.withFriends': 'Игра с друзьями',
|
'new.withFriends': 'Игра с друзьями',
|
||||||
'new.pickFriends': 'Кого пригласить',
|
'new.pickFriends': 'Кого пригласить',
|
||||||
|
'new.searchFriends': 'Поиск друзей',
|
||||||
|
'new.gameType': 'Тип игры',
|
||||||
'new.invite': 'Отправить приглашение',
|
'new.invite': 'Отправить приглашение',
|
||||||
'new.moveTime': 'Время на ход',
|
'new.moveTime': 'Время на ход',
|
||||||
'new.hintsPerPlayer': 'Подсказок на игрока',
|
'new.hintsPerPlayer': 'Подсказок на игрока',
|
||||||
|
|||||||
@@ -61,10 +61,17 @@
|
|||||||
// --- friend game ---
|
// --- friend game ---
|
||||||
let friends = $state<AccountRef[]>([]);
|
let friends = $state<AccountRef[]>([]);
|
||||||
let selected = $state<string[]>([]);
|
let selected = $state<string[]>([]);
|
||||||
|
let friendFilter = $state('');
|
||||||
let inviteVariant = $state<Variant>('english');
|
let inviteVariant = $state<Variant>('english');
|
||||||
let timeoutSecs = $state(86400);
|
let timeoutSecs = $state(86400);
|
||||||
let hints = $state(1);
|
let hints = $state(1);
|
||||||
|
|
||||||
|
const filteredFriends = $derived(
|
||||||
|
friendFilter.trim()
|
||||||
|
? friends.filter((f) => f.displayName.toLowerCase().includes(friendFilter.trim().toLowerCase()))
|
||||||
|
: friends,
|
||||||
|
);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (guest) return;
|
if (guest) return;
|
||||||
try {
|
try {
|
||||||
@@ -121,45 +128,45 @@
|
|||||||
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
|
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if friends.length === 0}
|
||||||
|
<p class="subtitle">{t('new.noFriends')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#if friends.length === 0}
|
<div class="fg">
|
||||||
<p class="subtitle">{t('new.noFriends')}</p>
|
<div class="picked">
|
||||||
{:else}
|
<span class="ftitle">{t('new.pickFriends')} ({selected.length})</span>
|
||||||
<h3>{t('new.pickFriends')}</h3>
|
<input class="search" bind:value={friendFilter} placeholder={t('new.searchFriends')} />
|
||||||
<div class="friends">
|
</div>
|
||||||
{#each friends as f (f.accountId)}
|
<div class="friends-scroll">
|
||||||
|
{#each filteredFriends as f (f.accountId)}
|
||||||
<label class="friend">
|
<label class="friend">
|
||||||
<input type="checkbox" checked={selected.includes(f.accountId)} onchange={() => toggle(f.accountId)} />
|
<input type="checkbox" checked={selected.includes(f.accountId)} onchange={() => toggle(f.accountId)} />
|
||||||
<span>{f.displayName}</span>
|
<span>{f.displayName}</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if filteredFriends.length === 0}<p class="muted">—</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
<h3>{t('new.title')}</h3>
|
<label class="field">
|
||||||
<div class="seg">
|
<span>{t('new.gameType')}</span>
|
||||||
{#each variants as v (v.id)}
|
<select bind:value={inviteVariant}>
|
||||||
<button class="opt" class:active={inviteVariant === v.id} onclick={() => (inviteVariant = v.id)}>{t(v.label)}</button>
|
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
|
||||||
{/each}
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>{t('new.moveTime')}</span>
|
||||||
|
<select bind:value={timeoutSecs}>
|
||||||
|
{#each timeouts as to (to.secs)}<option value={to.secs}>{t(to.key, { n: to.n })}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>{t('new.hintsPerPlayer')}</span>
|
||||||
|
<select bind:value={hints}>
|
||||||
|
{#each [0, 1, 2] as h (h)}<option value={h}>{h}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>{t('new.moveTime')}</h3>
|
|
||||||
<div class="seg">
|
|
||||||
{#each timeouts as to (to.secs)}
|
|
||||||
<button class="opt" class:active={timeoutSecs === to.secs} onclick={() => (timeoutSecs = to.secs)}>
|
|
||||||
{t(to.key, { n: to.n })}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>{t('new.hintsPerPlayer')}</h3>
|
|
||||||
<div class="seg">
|
|
||||||
{#each [0, 1, 2] as h (h)}
|
|
||||||
<button class="opt" class:active={hints === h} onclick={() => (hints = h)}>{h}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="invite" disabled={selected.length === 0} onclick={sendInvite}>{t('new.invite')}</button>
|
<button class="invite" disabled={selected.length === 0} onclick={sendInvite}>{t('new.invite')}</button>
|
||||||
{/if}
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -171,16 +178,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
h3 {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.variants {
|
.variants {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -219,7 +223,34 @@
|
|||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
border-color: var(--accent);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -233,8 +264,37 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--radius-sm);
|
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 {
|
.invite {
|
||||||
margin-top: 8px;
|
flex: 0 0 auto;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import {
|
import {
|
||||||
awayDurationOk,
|
awayDurationOk,
|
||||||
awayHours,
|
|
||||||
awayMinutes,
|
|
||||||
browserOffset,
|
browserOffset,
|
||||||
isOffsetZone,
|
isOffsetZone,
|
||||||
timezoneOffsets,
|
timezoneOffsets,
|
||||||
@@ -17,10 +15,8 @@
|
|||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let dn = $state('');
|
let dn = $state('');
|
||||||
let tz = $state('+00:00');
|
let tz = $state('+00:00');
|
||||||
let startH = $state('00');
|
let awayStart = $state('00:00');
|
||||||
let startM = $state('00');
|
let awayEnd = $state('07:00');
|
||||||
let endH = $state('07');
|
|
||||||
let endM = $state('00');
|
|
||||||
let blockChat = $state(false);
|
let blockChat = $state(false);
|
||||||
let blockFriendRequests = $state(false);
|
let blockFriendRequests = $state(false);
|
||||||
let emailInput = $state('');
|
let emailInput = $state('');
|
||||||
@@ -31,25 +27,21 @@
|
|||||||
const b = browserOffset();
|
const b = browserOffset();
|
||||||
return timezoneOffsets.includes(b) ? b : '+00:00';
|
return timezoneOffsets.includes(b) ? b : '+00:00';
|
||||||
}
|
}
|
||||||
function splitTime(hhmm: string): [string, string] {
|
function clockOr(value: string, fallback: string): string {
|
||||||
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
return /^\d{2}:\d{2}$/.test(value) ? value : fallback;
|
||||||
if (!m) return ['00', '00'];
|
|
||||||
return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
const p = app.profile!;
|
const p = app.profile!;
|
||||||
dn = p.displayName;
|
dn = p.displayName;
|
||||||
tz = isOffsetZone(p.timeZone) && timezoneOffsets.includes(p.timeZone) ? p.timeZone : defaultTz();
|
tz = isOffsetZone(p.timeZone) && timezoneOffsets.includes(p.timeZone) ? p.timeZone : defaultTz();
|
||||||
[startH, startM] = splitTime(p.awayStart);
|
awayStart = clockOr(p.awayStart, '00:00');
|
||||||
[endH, endM] = splitTime(p.awayEnd);
|
awayEnd = clockOr(p.awayEnd, '07:00');
|
||||||
blockChat = p.blockChat;
|
blockChat = p.blockChat;
|
||||||
blockFriendRequests = p.blockFriendRequests;
|
blockFriendRequests = p.blockFriendRequests;
|
||||||
editing = true;
|
editing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const awayStart = $derived(`${startH}:${startM}`);
|
|
||||||
const awayEnd = $derived(`${endH}:${endM}`);
|
|
||||||
const nameOk = $derived(validDisplayName(dn));
|
const nameOk = $derived(validDisplayName(dn));
|
||||||
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
|
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
|
||||||
const formValid = $derived(nameOk && awayOk);
|
const formValid = $derived(nameOk && awayOk);
|
||||||
@@ -120,16 +112,8 @@
|
|||||||
<fieldset class="away" class:invalid={!awayOk}>
|
<fieldset class="away" class:invalid={!awayOk}>
|
||||||
<legend>{t('profile.awayWindow')}</legend>
|
<legend>{t('profile.awayWindow')}</legend>
|
||||||
<div class="times">
|
<div class="times">
|
||||||
<span class="tlabel">{t('profile.from')}</span>
|
<label><span class="tlabel">{t('profile.from')}</span><input type="time" step="600" bind:value={awayStart} /></label>
|
||||||
<select bind:value={startH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
|
<label><span class="tlabel">{t('profile.to')}</span><input type="time" step="600" bind:value={awayEnd} /></label>
|
||||||
<span class="colon">:</span>
|
|
||||||
<select bind:value={startM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
|
|
||||||
</div>
|
|
||||||
<div class="times">
|
|
||||||
<span class="tlabel">{t('profile.to')}</span>
|
|
||||||
<select bind:value={endH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
|
|
||||||
<span class="colon">:</span>
|
|
||||||
<select bind:value={endM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{t('profile.awayHint')}</p>
|
<p class="muted">{t('profile.awayHint')}</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -271,17 +255,22 @@
|
|||||||
}
|
}
|
||||||
.times {
|
.times {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 12px;
|
||||||
gap: 8px;
|
}
|
||||||
|
.times label {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.times input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.tlabel {
|
.tlabel {
|
||||||
min-width: 2.5em;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.colon {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.check {
|
.check {
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user