Stage 8: UI social/account/history surfaces #9
@@ -594,13 +594,16 @@ Open details: deployment target/host; dashboards; load expectations.
|
|||||||
game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge
|
game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge
|
||||||
became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check
|
became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check
|
||||||
word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab
|
word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab
|
||||||
bar) instead of hiding it. A second **iPhone-simulator** pass then made the chat
|
bar) instead of hiding it. Two **iPhone-simulator** passes then made the chat and
|
||||||
and modals keyboard-aware (`dvh` sizing), returned the away window to a native
|
modals keyboard-aware (`dvh` plus a `visualViewport` listener that sizes the modal
|
||||||
`<input type="time" step="600">` (the iOS wheel with 10-minute steps; the timezone
|
backdrop to the area above the keyboard), reserved the rack height so a finished
|
||||||
stays a native offset `<select>`), reserved the rack height so a finished footer
|
footer does not collapse, and compacted the play-with-friends form (a searchable
|
||||||
does not collapse, and compacted the play-with-friends form (a searchable
|
bounded-scroll friend list, a pinned invite, and an explicit, **required game
|
||||||
bounded-scroll friend list, native game-type / move-time / hints selects in one
|
type** — a smart default is TODO-6). On the owner's call, **every profile / new-game
|
||||||
row, a pinned invite).
|
picker is a native `<select>`** (the away window as hour + 10-minute selects, the
|
||||||
|
timezone as a UTC-offset select): native time/wheel inputs render differently per
|
||||||
|
OS and can't be forced to match, and a select also avoids the iOS "clear" button
|
||||||
|
that would empty a time field.
|
||||||
|
|
||||||
## Deferred TODOs (cross-stage)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
@@ -639,3 +642,9 @@ Open details: deployment target/host; dashboards; load expectations.
|
|||||||
exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can
|
exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can
|
||||||
add you by scanning rather than typing. The code semantics (12 h TTL, single use,
|
add you by scanning rather than typing. The code semantics (12 h TTL, single use,
|
||||||
one active per issuer) stay as-is; only the delivery changes.
|
one active per issuer) stay as-is; only the delivery changes.
|
||||||
|
- **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).**
|
||||||
|
The play-with-friends form has no preselected variant today (an empty, required
|
||||||
|
pick). Default it from the player's history (the variant they play most, from
|
||||||
|
`account_stats` or a games query), falling back to their interface language
|
||||||
|
(en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
|
||||||
|
wrong.
|
||||||
|
|||||||
@@ -6,11 +6,38 @@
|
|||||||
onclose,
|
onclose,
|
||||||
children,
|
children,
|
||||||
}: { title?: string; onclose?: () => void; children?: Snippet } = $props();
|
}: { title?: string; onclose?: () => void; children?: Snippet } = $props();
|
||||||
|
|
||||||
|
// Track the visual viewport so the backdrop covers only the area above an open
|
||||||
|
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
|
||||||
|
// backdrop still centres it behind the keyboard. Sizing the backdrop to
|
||||||
|
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
|
||||||
|
let vh = $state(0);
|
||||||
|
let top = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||||
|
if (!vv) return;
|
||||||
|
const update = () => {
|
||||||
|
vh = vv.height;
|
||||||
|
top = vv.offsetTop;
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
vv.addEventListener('resize', update);
|
||||||
|
vv.addEventListener('scroll', update);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', update);
|
||||||
|
vv.removeEventListener('scroll', update);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="backdrop" onclick={() => onclose?.()}>
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
style:height={vh ? `${vh}px` : null}
|
||||||
|
style:top={vh ? `${top}px` : null}
|
||||||
|
onclick={() => onclose?.()}
|
||||||
|
>
|
||||||
<div class="sheet" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
<div class="sheet" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||||
{#if title}<h2>{title}</h2>{/if}
|
{#if title}<h2>{title}</h2>{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
@@ -20,7 +47,13 @@
|
|||||||
<style>
|
<style>
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
/* Base fallback; overridden inline to the visual-viewport height/top so the
|
||||||
|
backdrop (and the centred sheet) stay above an open mobile keyboard. */
|
||||||
|
height: 100dvh;
|
||||||
|
box-sizing: border-box;
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -62,7 +62,9 @@
|
|||||||
let friends = $state<AccountRef[]>([]);
|
let friends = $state<AccountRef[]>([]);
|
||||||
let selected = $state<string[]>([]);
|
let selected = $state<string[]>([]);
|
||||||
let friendFilter = $state('');
|
let friendFilter = $state('');
|
||||||
let inviteVariant = $state<Variant>('english');
|
// No default game type yet — the player must pick one (a smarter default from play
|
||||||
|
// history / language is TODO-6). '' renders the disabled placeholder option.
|
||||||
|
let inviteVariant = $state<Variant | ''>('');
|
||||||
let timeoutSecs = $state(86400);
|
let timeoutSecs = $state(86400);
|
||||||
let hints = $state(1);
|
let hints = $state(1);
|
||||||
|
|
||||||
@@ -86,7 +88,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendInvite() {
|
async function sendInvite() {
|
||||||
if (selected.length === 0 || selected.length > 3) return;
|
if (selected.length === 0 || selected.length > 3 || !inviteVariant) return;
|
||||||
try {
|
try {
|
||||||
await gateway.invitationCreate(selected, {
|
await gateway.invitationCreate(selected, {
|
||||||
variant: inviteVariant,
|
variant: inviteVariant,
|
||||||
@@ -148,7 +150,8 @@
|
|||||||
<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}>
|
<select bind:value={inviteVariant} class:placeholder={!inviteVariant}>
|
||||||
|
<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>
|
||||||
</label>
|
</label>
|
||||||
@@ -165,7 +168,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="invite" disabled={selected.length === 0} onclick={sendInvite}>{t('new.invite')}</button>
|
<button class="invite" disabled={selected.length === 0 || !inviteVariant} onclick={sendInvite}>{t('new.invite')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -289,6 +292,9 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
.field select.placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
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,
|
||||||
@@ -15,8 +17,12 @@
|
|||||||
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 awayStart = $state('00:00');
|
// Away start/end as hour + 10-minute parts, so the picker is a <select> like every
|
||||||
let awayEnd = $state('07:00');
|
// other profile control (consistent native control across iOS / desktop).
|
||||||
|
let startH = $state('00');
|
||||||
|
let startM = $state('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('');
|
||||||
@@ -27,21 +33,25 @@
|
|||||||
const b = browserOffset();
|
const b = browserOffset();
|
||||||
return timezoneOffsets.includes(b) ? b : '+00:00';
|
return timezoneOffsets.includes(b) ? b : '+00:00';
|
||||||
}
|
}
|
||||||
function clockOr(value: string, fallback: string): string {
|
function splitTime(hhmm: string): [string, string] {
|
||||||
return /^\d{2}:\d{2}$/.test(value) ? value : fallback;
|
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||||
|
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();
|
||||||
awayStart = clockOr(p.awayStart, '00:00');
|
[startH, startM] = splitTime(p.awayStart);
|
||||||
awayEnd = clockOr(p.awayEnd, '07:00');
|
[endH, endM] = splitTime(p.awayEnd);
|
||||||
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);
|
||||||
@@ -112,8 +122,16 @@
|
|||||||
<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">
|
||||||
<label><span class="tlabel">{t('profile.from')}</span><input type="time" step="600" bind:value={awayStart} /></label>
|
<span class="tlabel">{t('profile.from')}</span>
|
||||||
<label><span class="tlabel">{t('profile.to')}</span><input type="time" step="600" bind:value={awayEnd} /></label>
|
<select bind:value={startH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
|
||||||
|
<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>
|
||||||
@@ -255,22 +273,17 @@
|
|||||||
}
|
}
|
||||||
.times {
|
.times {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
align-items: center;
|
||||||
}
|
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