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 Screen from '../components/Screen.svelte';
|
||||||
import TabBar from '../components/TabBar.svelte';
|
import TabBar from '../components/TabBar.svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
|
import PinPad from '../components/PinPad.svelte';
|
||||||
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
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
|
// 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.
|
// per-account and irreversible. Only one row is revealed at a time.
|
||||||
let revealedId = $state<string | null>(null);
|
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;
|
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,
|
// 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.
|
// 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
|
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
|
||||||
drag = { id, x0: e.clientX, y0: e.clientY };
|
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;
|
if (!drag) return;
|
||||||
const dx = e.clientX - drag.x0;
|
const dx = e.clientX - drag.x0;
|
||||||
const dy = e.clientY - drag.y0;
|
const dy = e.clientY - drag.y0;
|
||||||
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
|
// Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal
|
||||||
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
|
// swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows
|
||||||
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
// ignore swipes and stay plain tap-to-open.
|
||||||
|
if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
|
||||||
swiped = true;
|
swiped = true;
|
||||||
revealedId = dx < 0 ? drag.id : null;
|
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) {
|
async function acceptInvite(inv: Invitation) {
|
||||||
try {
|
try {
|
||||||
const r = await gateway.invitationAccept(inv.id);
|
const r = await gateway.invitationAccept(inv.id);
|
||||||
@@ -315,15 +328,15 @@
|
|||||||
<div class="list">
|
<div class="list">
|
||||||
{#each group.list as g (g.id)}
|
{#each group.list as g (g.id)}
|
||||||
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
|
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
|
||||||
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
|
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
|
||||||
{#if group.finished}
|
{#if group.finished || g.hotseat}
|
||||||
<button class="del" onclick={() => hide(g.id)} disabled={!connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button
|
<button
|
||||||
class="open"
|
class="open"
|
||||||
onpointerdown={(e) => onRowDown(e, g.id)}
|
onpointerdown={(e) => onRowDown(e, g.id)}
|
||||||
onpointerup={(e) => onRowUp(e, group.finished)}
|
onpointerup={(e) => onRowUp(e, group.finished || !!g.hotseat)}
|
||||||
onclick={() => openGame(g)}
|
onclick={() => openGame(g)}
|
||||||
>
|
>
|
||||||
<span class="info">
|
<span class="info">
|
||||||
@@ -345,7 +358,7 @@
|
|||||||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
|
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
|
||||||
{/key}
|
{/key}
|
||||||
</button>
|
</button>
|
||||||
{#if group.finished}
|
{#if group.finished || g.hotseat}
|
||||||
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}>⋮</button>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
|
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
|
||||||
@@ -378,6 +391,21 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/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()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||||||
|
|||||||
Reference in New Issue
Block a user