feat(offline): hotseat lobby delete behind the master PIN
Lobby.svelte: hotseat games show the kebab / swipe delete in BOTH the active and finished groups (not finished-only); deleting one opens a master-PIN pad first — active = terminate (no result), finished = remove — so the last mover cannot instantly wipe a game. Local games stay deletable offline. Non-hotseat games are unchanged (finished-only, free).
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import PinPad from '../components/PinPad.svelte';
|
||||
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
@@ -186,6 +187,9 @@
|
||||
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
|
||||
// per-account and irreversible. Only one row is revealed at a time.
|
||||
let revealedId = $state<string | null>(null);
|
||||
// A hotseat game's deletion is gated by the host (master) PIN — both active (terminate the game)
|
||||
// and finished; pinTarget holds the game id while its PIN pad is open.
|
||||
let pinTarget = $state<string | null>(null);
|
||||
let drag: { id: string; x0: number; y0: number } | null = null;
|
||||
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
|
||||
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
|
||||
@@ -196,13 +200,14 @@
|
||||
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
|
||||
drag = { id, x0: e.clientX, y0: e.clientY };
|
||||
}
|
||||
function onRowUp(e: PointerEvent, finished: boolean): void {
|
||||
function onRowUp(e: PointerEvent, revealable: boolean): void {
|
||||
if (!drag) return;
|
||||
const dx = e.clientX - drag.x0;
|
||||
const dy = e.clientY - drag.y0;
|
||||
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
|
||||
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
|
||||
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||||
// Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal
|
||||
// swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows
|
||||
// ignore swipes and stay plain tap-to-open.
|
||||
if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||||
swiped = true;
|
||||
revealedId = dx < 0 ? drag.id : null;
|
||||
}
|
||||
@@ -240,6 +245,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// startDelete routes the row's delete: a hotseat game (active or finished) is gated behind the host
|
||||
// master PIN (so the last mover cannot instantly wipe the game); any other finished game deletes at
|
||||
// once.
|
||||
function startDelete(g: GameView): void {
|
||||
if (g.hotseat) pinTarget = g.id;
|
||||
else void hide(g.id);
|
||||
}
|
||||
|
||||
async function acceptInvite(inv: Invitation) {
|
||||
try {
|
||||
const r = await gateway.invitationAccept(inv.id);
|
||||
@@ -315,15 +328,15 @@
|
||||
<div class="list">
|
||||
{#each group.list as g (g.id)}
|
||||
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
|
||||
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
||||
{#if group.finished}
|
||||
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
|
||||
{#if group.finished || g.hotseat}
|
||||
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<button
|
||||
class="open"
|
||||
onpointerdown={(e) => onRowDown(e, g.id)}
|
||||
onpointerup={(e) => onRowUp(e, group.finished)}
|
||||
onpointerup={(e) => onRowUp(e, group.finished || !!g.hotseat)}
|
||||
onclick={() => openGame(g)}
|
||||
>
|
||||
<span class="info">
|
||||
@@ -345,7 +358,7 @@
|
||||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
|
||||
{/key}
|
||||
</button>
|
||||
{#if group.finished}
|
||||
{#if group.finished || g.hotseat}
|
||||
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
||||
{:else}
|
||||
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
|
||||
@@ -378,6 +391,21 @@
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<!-- Deleting a hotseat game (active or finished) is gated by the host master PIN. -->
|
||||
{#if pinTarget}
|
||||
<PinPad
|
||||
mode="verify"
|
||||
title={t('hotseat.hostPin')}
|
||||
verify={(p) => localSource.verifyHostPin(pinTarget ?? '', p)}
|
||||
onclose={() => (pinTarget = null)}
|
||||
onresult={() => {
|
||||
const id = pinTarget;
|
||||
pinTarget = null;
|
||||
if (id) void hide(id);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#snippet tabbar()}
|
||||
<TabBar>
|
||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||
|
||||
Reference in New Issue
Block a user