Stage 17 (#3,#5,#10): hover-hold drag zoom, always-editable profile, drag-back + double-tap recall
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s
- Board drag now auto-zooms toward a cell after holding the tile over it ~1s (#3). - Profile is inline-editable: drop the Edit/Cancel toggle, form is always shown for durable accounts; hint balance stays read-only; re-populate after link/merge (#5). - A pending tile recalls by double-tap (same cell) or by dragging it back onto the rack (unzoomed board); a single tap no longer recalls (#10). - e2e: lock double-tap recall + single-tap no-op; drop the removed Edit-profile click.
This commit is contained in:
@@ -29,6 +29,25 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page })
|
|||||||
await expect(page.locator('.make')).toBeHidden();
|
await expect(page.locator('.make')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => {
|
||||||
|
await openGame(page);
|
||||||
|
await page.locator('.rack .tile').first().click();
|
||||||
|
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||||
|
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||||
|
|
||||||
|
// A single tap must NOT recall it (changed in Stage 17 — recall was too easy to trigger).
|
||||||
|
await page.waitForTimeout(350); // clear the double-tap window from the placing tap
|
||||||
|
await page.locator('[data-cell].pending').first().click();
|
||||||
|
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||||
|
|
||||||
|
// A double-tap (two synchronous clicks on the same cell) recalls it to the rack.
|
||||||
|
await page.locator('[data-cell].pending').first().evaluate((el: HTMLElement) => {
|
||||||
|
el.click();
|
||||||
|
el.click();
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('history slides the board down and closes on a board tap', async ({ page }) => {
|
test('history slides the board down and closes on a board tap', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
await page.locator('.burger').click();
|
await page.locator('.burger').click();
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ test('profile edit saves a new display name', async ({ page }) => {
|
|||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await page.locator('.burger').first().click();
|
await page.locator('.burger').first().click();
|
||||||
await page.getByRole('button', { name: /Profile/ }).click();
|
await page.getByRole('button', { name: /Profile/ }).click();
|
||||||
await page.getByRole('button', { name: /Edit profile/ }).click();
|
|
||||||
await page.locator('.edit input').first().fill('Kaya Test');
|
await page.locator('.edit input').first().fill('Kaya Test');
|
||||||
await page.getByRole('button', { name: /^Save$/ }).click();
|
await page.getByRole('button', { name: /^Save$/ }).click();
|
||||||
await expect(page.locator('.name')).toHaveText('Kaya Test');
|
await expect(page.locator('.name')).toHaveText('Kaya Test');
|
||||||
@@ -127,7 +126,6 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
|
|||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await page.locator('.burger').first().click();
|
await page.locator('.burger').first().click();
|
||||||
await page.getByRole('button', { name: /Profile/ }).click();
|
await page.getByRole('button', { name: /Profile/ }).click();
|
||||||
await page.getByRole('button', { name: /Edit profile/ }).click();
|
|
||||||
|
|
||||||
const name = page.locator('.edit input').first();
|
const name = page.locator('.edit input').first();
|
||||||
const save = page.getByRole('button', { name: /^Save$/ });
|
const save = page.getByRole('button', { name: /^Save$/ });
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
focus,
|
focus,
|
||||||
oncell,
|
oncell,
|
||||||
ontogglezoom,
|
ontogglezoom,
|
||||||
|
onrecall,
|
||||||
|
onpenddown,
|
||||||
}: {
|
}: {
|
||||||
board: (BoardCell | null)[][];
|
board: (BoardCell | null)[][];
|
||||||
premium: Premium[][];
|
premium: Premium[][];
|
||||||
@@ -34,6 +36,10 @@
|
|||||||
focus: { row: number; col: number } | null;
|
focus: { row: number; col: number } | null;
|
||||||
oncell: (row: number, col: number) => void;
|
oncell: (row: number, col: number) => void;
|
||||||
ontogglezoom: (row: number, col: number) => void;
|
ontogglezoom: (row: number, col: number) => void;
|
||||||
|
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
|
||||||
|
onrecall: (row: number, col: number) => void;
|
||||||
|
/** Pointer-down on a pending cell, to start dragging that tile back to the rack. */
|
||||||
|
onpenddown: (e: PointerEvent, row: number, col: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const Z = 1.85;
|
const Z = 1.85;
|
||||||
@@ -60,16 +66,26 @@
|
|||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll).
|
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
|
||||||
|
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
|
||||||
|
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
|
||||||
|
// scroll of the zoomed board is never hijacked by a pinch gesture.
|
||||||
let lastTap = 0;
|
let lastTap = 0;
|
||||||
|
let lastCell = '';
|
||||||
function onTap(row: number, col: number) {
|
function onTap(row: number, col: number) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTap < 300) {
|
const k = key(row, col);
|
||||||
ontogglezoom(row, col); // zoom toward the double-tapped cell, not the top-left
|
// A double-tap counts only when it lands twice on the same cell, so quick taps across
|
||||||
|
// different cells don't coalesce into a stray recall/zoom.
|
||||||
|
if (k === lastCell && now - lastTap < 300) {
|
||||||
lastTap = 0;
|
lastTap = 0;
|
||||||
|
lastCell = '';
|
||||||
|
if (pending.has(k)) onrecall(row, col);
|
||||||
|
else ontogglezoom(row, col);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastTap = now;
|
lastTap = now;
|
||||||
|
lastCell = k;
|
||||||
oncell(row, col);
|
oncell(row, col);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +111,7 @@
|
|||||||
data-row={r}
|
data-row={r}
|
||||||
data-col={c}
|
data-col={c}
|
||||||
onclick={() => onTap(r, c)}
|
onclick={() => onTap(r, c)}
|
||||||
|
onpointerdown={(e) => { if (!!p && !cell) onpenddown(e, r, c); }}
|
||||||
>
|
>
|
||||||
{#if letter}
|
{#if letter}
|
||||||
<span class="letter">{letter}</span>
|
<span class="letter">{letter}</span>
|
||||||
|
|||||||
+78
-21
@@ -143,61 +143,112 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- tile placement: pointer drag + tap ---
|
// --- tile placement: pointer drag + tap ---
|
||||||
let downInfo: { index: number; x0: number; y0: number } | null = null;
|
// A drag carries its source: a rack slot (lift a tile onto the board) or a pending
|
||||||
|
// board cell (drag the tile back to the rack). downInfo also holds the press origin,
|
||||||
|
// for the movement threshold that distinguishes a drag from a tap.
|
||||||
|
type DragSrc = { from: 'rack'; index: number } | { from: 'board'; row: number; col: number };
|
||||||
|
let downInfo: { src: DragSrc; x0: number; y0: number } | null = null;
|
||||||
let dragMoved = false;
|
let dragMoved = false;
|
||||||
let swallowClick = false;
|
let swallowClick = false;
|
||||||
|
let hoverKey = '';
|
||||||
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function onRackDown(e: PointerEvent, index: number) {
|
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||||
if (!isMyTurn || busy) return;
|
downInfo = { src, x0: e.clientX, y0: e.clientY };
|
||||||
downInfo = { index, x0: e.clientX, y0: e.clientY };
|
|
||||||
dragMoved = false;
|
dragMoved = false;
|
||||||
window.addEventListener('pointermove', onWinMove);
|
window.addEventListener('pointermove', onWinMove);
|
||||||
window.addEventListener('pointerup', onWinUp);
|
window.addEventListener('pointerup', onWinUp);
|
||||||
}
|
}
|
||||||
|
function onRackDown(e: PointerEvent, index: number) {
|
||||||
|
if (!isMyTurn || busy) return;
|
||||||
|
beginDrag({ from: 'rack', index }, e);
|
||||||
|
}
|
||||||
|
// A pending tile can be dragged back to the rack, but only on the unzoomed board: when
|
||||||
|
// zoomed the one-finger gesture scrolls the board, so recall there is via double-tap.
|
||||||
|
function onBoardDown(e: PointerEvent, row: number, col: number) {
|
||||||
|
if (!isMyTurn || busy || zoomed) return;
|
||||||
|
beginDrag({ from: 'board', row, col }, e);
|
||||||
|
}
|
||||||
|
function cellUnder(x: number, y: number): { row: number; col: number } | null {
|
||||||
|
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as
|
||||||
|
| HTMLElement
|
||||||
|
| null;
|
||||||
|
if (!el?.dataset.row || !el.dataset.col) return null;
|
||||||
|
return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
|
||||||
|
}
|
||||||
|
function clearHover() {
|
||||||
|
if (hoverTimer) clearTimeout(hoverTimer);
|
||||||
|
hoverTimer = null;
|
||||||
|
hoverKey = '';
|
||||||
|
}
|
||||||
function onWinMove(e: PointerEvent) {
|
function onWinMove(e: PointerEvent) {
|
||||||
if (!downInfo) return;
|
if (!downInfo) return;
|
||||||
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
|
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
|
||||||
dragMoved = true;
|
dragMoved = true;
|
||||||
const slot = placement.rack[downInfo.index];
|
const src = downInfo.src;
|
||||||
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
|
const letter =
|
||||||
// No zoom on drag start: the player may still change their mind. The zoom
|
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
|
||||||
// (and centring) happens on drop, in attemptPlace.
|
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
|
||||||
|
// No zoom on drag start: the player may still change their mind. Holding the tile
|
||||||
|
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
|
||||||
|
}
|
||||||
|
if (!drag) return;
|
||||||
|
drag = { ...drag, x: e.clientX, y: e.clientY };
|
||||||
|
const c = cellUnder(e.clientX, e.clientY);
|
||||||
|
const ck = c ? `${c.row},${c.col}` : '';
|
||||||
|
if (ck !== hoverKey) {
|
||||||
|
hoverKey = ck;
|
||||||
|
if (hoverTimer) clearTimeout(hoverTimer);
|
||||||
|
hoverTimer = c
|
||||||
|
? setTimeout(() => {
|
||||||
|
// Still holding the tile over this cell: magnify into it.
|
||||||
|
if (drag && isCoarse()) {
|
||||||
|
focus = c;
|
||||||
|
zoomed = true;
|
||||||
|
telegramHaptic('light');
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
|
|
||||||
}
|
}
|
||||||
function onWinUp(e: PointerEvent) {
|
function onWinUp(e: PointerEvent) {
|
||||||
window.removeEventListener('pointermove', onWinMove);
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
window.removeEventListener('pointerup', onWinUp);
|
window.removeEventListener('pointerup', onWinUp);
|
||||||
|
clearHover();
|
||||||
const di = downInfo;
|
const di = downInfo;
|
||||||
downInfo = null;
|
downInfo = null;
|
||||||
if (drag && dragMoved && di) {
|
if (drag && dragMoved && di) {
|
||||||
const el = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(
|
|
||||||
'[data-cell]',
|
|
||||||
) as HTMLElement | null;
|
|
||||||
drag = null;
|
drag = null;
|
||||||
if (el?.dataset.row && el.dataset.col) {
|
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
|
||||||
attemptPlace(di.index, Number(el.dataset.row), Number(el.dataset.col));
|
const cell = cellUnder(e.clientX, e.clientY);
|
||||||
|
if (di.src.from === 'rack' && cell) {
|
||||||
|
attemptPlace(di.src.index, cell.row, cell.col);
|
||||||
|
} else if (di.src.from === 'board' && onRack) {
|
||||||
|
// Dropped a pending tile back onto the rack → recall it to its original slot.
|
||||||
|
placement = recallAt(placement, di.src.row, di.src.col);
|
||||||
|
recompute();
|
||||||
}
|
}
|
||||||
swallowClick = true;
|
swallowClick = true;
|
||||||
setTimeout(() => (swallowClick = false), 60);
|
setTimeout(() => (swallowClick = false), 60);
|
||||||
} else if (di) {
|
} else if (di && di.src.from === 'rack') {
|
||||||
selected = selected === di.index ? null : di.index;
|
selected = selected === di.src.index ? null : di.src.index;
|
||||||
|
drag = null;
|
||||||
|
} else {
|
||||||
drag = null;
|
drag = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('pointermove', onWinMove);
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
window.removeEventListener('pointerup', onWinUp);
|
window.removeEventListener('pointerup', onWinUp);
|
||||||
|
clearHover();
|
||||||
telegramClosingConfirmation(false);
|
telegramClosingConfirmation(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
function onCell(row: number, col: number) {
|
function onCell(row: number, col: number) {
|
||||||
if (swallowClick) return;
|
if (swallowClick) return;
|
||||||
if (pendingMap.has(`${row},${col}`)) {
|
// A pending tile is recalled by a double-tap or by dragging it back to the rack, not
|
||||||
placement = recallAt(placement, row, col);
|
// by a single tap (which recalled too easily — Stage 17).
|
||||||
recompute();
|
if (pendingMap.has(`${row},${col}`)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
// A committed tile already sits here: keep the rack selection so a stray tap
|
// A committed tile already sits here: keep the rack selection so a stray tap
|
||||||
// on an occupied cell doesn't cancel placement — wait for an empty cell.
|
// on an occupied cell doesn't cancel placement — wait for an empty cell.
|
||||||
@@ -206,6 +257,10 @@
|
|||||||
selected = null;
|
selected = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function onRecall(row: number, col: number) {
|
||||||
|
placement = recallAt(placement, row, col);
|
||||||
|
recompute();
|
||||||
|
}
|
||||||
function attemptPlace(index: number, row: number, col: number) {
|
function attemptPlace(index: number, row: number, col: number) {
|
||||||
if (board[row]?.[col]) return;
|
if (board[row]?.[col]) return;
|
||||||
if (pendingMap.has(`${row},${col}`)) return;
|
if (pendingMap.has(`${row},${col}`)) return;
|
||||||
@@ -512,6 +567,8 @@
|
|||||||
{focus}
|
{focus}
|
||||||
oncell={onCell}
|
oncell={onCell}
|
||||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||||
|
onrecall={onRecall}
|
||||||
|
onpenddown={onBoardDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
const visible = $derived(slots.filter((s) => !s.used));
|
const visible = $derived(slots.filter((s) => !s.used));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rack">
|
<div class="rack" data-rack>
|
||||||
{#each visible as slot (slot.index)}
|
{#each visible as slot (slot.index)}
|
||||||
<button
|
<button
|
||||||
class="tile"
|
class="tile"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import Screen from '../components/Screen.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
||||||
@@ -16,7 +17,6 @@
|
|||||||
validEmail,
|
validEmail,
|
||||||
} from '../lib/profileValidation';
|
} from '../lib/profileValidation';
|
||||||
|
|
||||||
let editing = $state(false);
|
|
||||||
let dn = $state('');
|
let dn = $state('');
|
||||||
let tz = $state('+00:00');
|
let tz = $state('+00:00');
|
||||||
// Away start/end as hour + 10-minute parts, so the picker is a <select> like every
|
// Away start/end as hour + 10-minute parts, so the picker is a <select> like every
|
||||||
@@ -47,8 +47,12 @@
|
|||||||
return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00'];
|
return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit() {
|
// populate loads the editable form from the current profile. The profile screen is
|
||||||
const p = app.profile!;
|
// edited inline (no edit/cancel toggle, Stage 17), so this runs on mount and after a
|
||||||
|
// link/merge swaps the active account.
|
||||||
|
function populate() {
|
||||||
|
const p = app.profile;
|
||||||
|
if (!p) return;
|
||||||
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);
|
[startH, startM] = splitTime(p.awayStart);
|
||||||
@@ -56,8 +60,8 @@
|
|||||||
blockChat = p.blockChat;
|
blockChat = p.blockChat;
|
||||||
blockFriendRequests = p.blockFriendRequests;
|
blockFriendRequests = p.blockFriendRequests;
|
||||||
notificationsInAppOnly = p.notificationsInAppOnly;
|
notificationsInAppOnly = p.notificationsInAppOnly;
|
||||||
editing = true;
|
|
||||||
}
|
}
|
||||||
|
onMount(populate);
|
||||||
|
|
||||||
const awayStart = $derived(`${startH}:${startM}`);
|
const awayStart = $derived(`${startH}:${startM}`);
|
||||||
const awayEnd = $derived(`${endH}:${endM}`);
|
const awayEnd = $derived(`${endH}:${endM}`);
|
||||||
@@ -79,7 +83,6 @@
|
|||||||
blockFriendRequests,
|
blockFriendRequests,
|
||||||
notificationsInAppOnly,
|
notificationsInAppOnly,
|
||||||
});
|
});
|
||||||
editing = false;
|
|
||||||
showToast(t('profile.saved'));
|
showToast(t('profile.saved'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await applyLinkResult(r);
|
await applyLinkResult(r);
|
||||||
|
populate();
|
||||||
resetEmail();
|
resetEmail();
|
||||||
showToast(t('profile.linked'));
|
showToast(t('profile.linked'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -129,6 +133,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await applyLinkResult(r);
|
await applyLinkResult(r);
|
||||||
|
populate();
|
||||||
showToast(t('profile.linked'));
|
showToast(t('profile.linked'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
@@ -143,6 +148,7 @@
|
|||||||
? await gateway.linkEmailMerge(emailInput.trim(), codeInput.trim())
|
? await gateway.linkEmailMerge(emailInput.trim(), codeInput.trim())
|
||||||
: await gateway.linkTelegramMerge(tgData);
|
: await gateway.linkTelegramMerge(tgData);
|
||||||
await applyLinkResult(r);
|
await applyLinkResult(r);
|
||||||
|
populate();
|
||||||
pendingMerge = null;
|
pendingMerge = null;
|
||||||
tgData = '';
|
tgData = '';
|
||||||
resetEmail();
|
resetEmail();
|
||||||
@@ -160,7 +166,11 @@
|
|||||||
<div class="name">{p.displayName}</div>
|
<div class="name">{p.displayName}</div>
|
||||||
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
|
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
|
||||||
|
|
||||||
{#if editing}
|
<div class="hintbal"><span>{t('profile.hintBalance')}</span><b>{p.hintBalance}</b></div>
|
||||||
|
|
||||||
|
{#if p.isGuest}
|
||||||
|
<p class="muted">{t('profile.guestLocked')}</p>
|
||||||
|
{:else}
|
||||||
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
|
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
|
||||||
<label>
|
<label>
|
||||||
<span>{t('profile.displayName')}</span>
|
<span>{t('profile.displayName')}</span>
|
||||||
@@ -202,57 +212,41 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="formacts">
|
<div class="formacts">
|
||||||
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
|
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
|
||||||
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
|
||||||
<dl>
|
|
||||||
<dt>{t('profile.timezone')}</dt>
|
|
||||||
<dd>{p.timeZone}</dd>
|
|
||||||
<dt>{t('profile.awayWindow')}</dt>
|
|
||||||
<dd>{p.awayStart}–{p.awayEnd}</dd>
|
|
||||||
<dt>{t('profile.hintBalance')}</dt>
|
|
||||||
<dd>{p.hintBalance}</dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{#if p.isGuest}
|
|
||||||
<p class="muted">{t('profile.guestLocked')}</p>
|
|
||||||
{:else}
|
|
||||||
<button class="btn" onclick={startEdit}>{t('profile.edit')}</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Linking & merge (Stage 11). Shown to everyone, including guests, who
|
|
||||||
upgrade by binding their first identity. -->
|
|
||||||
<section class="emailbox">
|
|
||||||
<h3>{t('profile.linkAccount')}</h3>
|
|
||||||
{#if !emailSent}
|
|
||||||
<div class="addrow">
|
|
||||||
<input
|
|
||||||
class:invalid={emailInput.length > 0 && !emailOk}
|
|
||||||
bind:value={emailInput}
|
|
||||||
placeholder={t('login.emailPlaceholder')}
|
|
||||||
type="email"
|
|
||||||
/>
|
|
||||||
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="addrow">
|
|
||||||
<input
|
|
||||||
class="codein"
|
|
||||||
bind:value={codeInput}
|
|
||||||
placeholder={t('profile.emailCode')}
|
|
||||||
inputmode="numeric"
|
|
||||||
maxlength="6"
|
|
||||||
/>
|
|
||||||
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if telegramLinkable}
|
|
||||||
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Linking & merge (Stage 11). Shown to everyone, including guests, who
|
||||||
|
upgrade by binding their first identity. -->
|
||||||
|
<section class="emailbox">
|
||||||
|
<h3>{t('profile.linkAccount')}</h3>
|
||||||
|
{#if !emailSent}
|
||||||
|
<div class="addrow">
|
||||||
|
<input
|
||||||
|
class:invalid={emailInput.length > 0 && !emailOk}
|
||||||
|
bind:value={emailInput}
|
||||||
|
placeholder={t('login.emailPlaceholder')}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="addrow">
|
||||||
|
<input
|
||||||
|
class="codein"
|
||||||
|
bind:value={codeInput}
|
||||||
|
placeholder={t('profile.emailCode')}
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
/>
|
||||||
|
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if telegramLinkable}
|
||||||
|
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Logout is hidden for now (Stage 17) but kept wired — drop `hidden` to re-enable
|
<!-- Logout is hidden for now (Stage 17) but kept wired — drop `hidden` to re-enable
|
||||||
once its entry point is decided; logout() also still runs on an invalid session. -->
|
once its entry point is decided; logout() also still runs on an invalid session. -->
|
||||||
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
|
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
|
||||||
@@ -290,18 +284,14 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
dl {
|
.hintbal {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: auto 1fr;
|
justify-content: space-between;
|
||||||
gap: 6px 16px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
dt {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
dd {
|
.hintbal b {
|
||||||
margin: 0;
|
color: var(--text);
|
||||||
text-align: right;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user