Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import React, { useEffect, useState, useCallback } from 'react';
import { Button, Empty } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import { FastForwardOutlined, FastBackwardOutlined } from '@ant-design/icons';
import { useSwipeable } from 'react-swipeable';
import { useRouter, useSearchParams } from 'next/navigation';
import { track } from '@vercel/analytics';
Expand All @@ -20,9 +20,10 @@ type PlayViewClientProps = {
angle: Angle;
};

// Minimum horizontal swipe distance (in pixels) required to trigger navigation
// This value balances responsiveness with preventing accidental swipes
const SWIPE_THRESHOLD = 80;
// Swipe threshold in pixels to trigger navigation
const SWIPE_THRESHOLD = 100;
// Maximum swipe distance (matches queue-control-bar)
const MAX_SWIPE = 120;

const PlayViewClient: React.FC<PlayViewClientProps> = ({ boardDetails, initialClimb, angle }) => {
const router = useRouter();
Expand All @@ -36,19 +37,44 @@ const PlayViewClient: React.FC<PlayViewClientProps> = ({ boardDetails, initialCl
} = useQueueContext();

const [swipeOffset, setSwipeOffset] = useState(0);
const [showSwipeHint, setShowSwipeHint] = useState(true);
const [showSwipeHint, setShowSwipeHint] = useState(false);
const [isClient, setIsClient] = useState(false);

// Use queue's current climb if available, otherwise use initial climb from SSR
const displayClimb = currentClimb || initialClimb;

// Hide swipe hint after first interaction
// Check if user has already swiped before (persisted in localStorage)
// Only runs on client to avoid SSR hydration mismatch
useEffect(() => {
const timer = setTimeout(() => {
setShowSwipeHint(false);
}, 3000);
return () => clearTimeout(timer);
setIsClient(true);
try {
const hasSwipedBefore = localStorage.getItem('playViewSwipeHintDismissed');
if (hasSwipedBefore) {
return;
}

// Show hint for new users, then auto-hide after 3 seconds
setShowSwipeHint(true);
const timer = setTimeout(() => {
setShowSwipeHint(false);
}, 3000);
return () => clearTimeout(timer);
} catch {
// localStorage unavailable (private browsing, quota exceeded, etc.)
}
}, []);

const dismissSwipeHintPermanently = useCallback(() => {
if (showSwipeHint) {
setShowSwipeHint(false);
try {
localStorage.setItem('playViewSwipeHintDismissed', 'true');
} catch {
// localStorage unavailable (private browsing, quota exceeded, etc.)
}
}
}, [showSwipeHint]);

const getBackToListUrl = useCallback(() => {
const { board_name, layout_name, size_name, size_description, set_names } = boardDetails;

Expand Down Expand Up @@ -125,31 +151,29 @@ const PlayViewClient: React.FC<PlayViewClientProps> = ({ boardDetails, initialCl
const swipeHandlers = useSwipeable({
onSwiping: (eventData) => {
const { deltaX } = eventData;
// Only allow horizontal swiping
if (Math.abs(deltaX) > Math.abs(eventData.deltaY)) {
setSwipeOffset(deltaX);
setShowSwipeHint(false);
}
// Clamp the offset within bounds (matches queue-control-bar)
const clampedOffset = Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, deltaX));
setSwipeOffset(clampedOffset);
dismissSwipeHintPermanently();
},
onSwipedLeft: (eventData) => {
setSwipeOffset(0);
if (Math.abs(eventData.deltaX) >= SWIPE_THRESHOLD) {
handleNext();
}
setSwipeOffset(0);
},
onSwipedRight: (eventData) => {
setSwipeOffset(0);
if (Math.abs(eventData.deltaX) >= SWIPE_THRESHOLD) {
handlePrevious();
}
setSwipeOffset(0);
},
onTouchEndOrOnMouseUp: () => {
setSwipeOffset(0);
},
trackMouse: false,
trackTouch: true,
preventScrollOnSwipe: false,
delta: 10,
preventScrollOnSwipe: true,
});

const nextItem = getNextClimbQueueItem();
Expand All @@ -173,48 +197,61 @@ const PlayViewClient: React.FC<PlayViewClientProps> = ({ boardDetails, initialCl
{/* Main Content with Swipe */}
<div className={styles.contentWrapper}>
{/* Climb title - horizontal layout with grade on right */}
<div
className={styles.climbTitleContainer}
style={{
padding: `${themeTokens.spacing[1]}px ${themeTokens.spacing[3]}px`,
}}
>
<div className={styles.climbTitleContainer}>
<ClimbTitle climb={displayClimb} layout="horizontal" showSetterInfo />
</div>
<div {...swipeHandlers} className={styles.swipeContainer}>
{/* Swipe indicators */}
{prevItem && (
<div className={styles.swipeWrapper}>
{/* Left action background (previous - revealed on swipe right) */}
{prevItem && isClient && (
<div
className={`${styles.swipeIndicator} ${styles.swipeIndicatorLeft} ${
swipeOffset > SWIPE_THRESHOLD / 2 ? styles.swipeIndicatorVisible : ''
}`}
className={`${styles.swipeAction} ${styles.swipeActionLeft}`}
style={{
backgroundColor: themeTokens.colors.primary,
opacity: Math.min(1, swipeOffset / SWIPE_THRESHOLD),
visibility: swipeOffset > 0 ? 'visible' : 'hidden',
}}
>
<LeftOutlined style={{ fontSize: 24 }} />
<FastBackwardOutlined className={styles.swipeActionIcon} />
</div>
)}
{nextItem && (

{/* Right action background (next - revealed on swipe left) */}
{nextItem && isClient && (
<div
className={`${styles.swipeIndicator} ${styles.swipeIndicatorRight} ${
swipeOffset < -SWIPE_THRESHOLD / 2 ? styles.swipeIndicatorVisible : ''
}`}
className={`${styles.swipeAction} ${styles.swipeActionRight}`}
style={{
backgroundColor: themeTokens.colors.primary,
opacity: Math.min(1, Math.abs(swipeOffset) / SWIPE_THRESHOLD),
visibility: swipeOffset < 0 ? 'visible' : 'hidden',
}}
>
<RightOutlined style={{ fontSize: 24 }} />
<FastForwardOutlined className={styles.swipeActionIcon} />
</div>
)}

<div className={styles.boardContainer}>
<BoardRenderer
boardDetails={boardDetails}
litUpHoldsMap={displayClimb.litUpHoldsMap}
mirrored={!!displayClimb.mirrored}
fillHeight
/>
</div>
{/* Swipeable content */}
<div
{...swipeHandlers}
className={styles.swipeContainer}
style={{
transform: isClient ? `translateX(${swipeOffset}px)` : undefined,
transition: swipeOffset === 0 ? `transform ${themeTokens.transitions.fast}` : 'none',
}}
>
<div className={styles.boardContainer}>
<BoardRenderer
boardDetails={boardDetails}
litUpHoldsMap={displayClimb.litUpHoldsMap}
mirrored={!!displayClimb.mirrored}
fillHeight
/>
</div>

{/* Swipe hint for mobile */}
{showSwipeHint && queue.length > 1 && (
<div className={styles.swipeHint}>Swipe left/right to navigate</div>
)}
{/* Swipe hint for mobile */}
{showSwipeHint && queue.length > 1 && (
<div className={styles.swipeHint}>Swipe left/right to navigate</div>
)}
</div>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
.climbTitleContainer {
width: 100%;
flex-shrink: 0;
padding: 4px 12px;
}

.swipeWrapper {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
min-height: 0;
}

.swipeContainer {
Expand All @@ -31,6 +41,36 @@
touch-action: pan-y pinch-zoom;
overflow: hidden;
min-height: 0;
position: relative;
z-index: 1;
}

/* Action backgrounds revealed on swipe (hidden on desktop) */
.swipeAction {
position: absolute;
top: 0;
bottom: 0;
width: 120px;
display: flex;
align-items: center;
z-index: 0;
}

.swipeActionLeft {
left: 0;
justify-content: flex-start;
padding-left: 16px;
}

.swipeActionRight {
right: 0;
justify-content: flex-end;
padding-right: 16px;
}

.swipeActionIcon {
color: white;
font-size: 24px;
}

.boardContainer {
Expand Down Expand Up @@ -70,32 +110,6 @@
transition: opacity 0.3s;
}

.swipeIndicator {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: rgba(0, 0, 0, 0.3);
color: white;
padding: 12px;
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
z-index: 10;
}

.swipeIndicatorLeft {
left: 8px;
}

.swipeIndicatorRight {
right: 8px;
}

.swipeIndicatorVisible {
opacity: 1;
}

/* Desktop adjustments */
@media (min-width: 768px) {
.boardContainer {
Expand All @@ -106,4 +120,8 @@
.swipeHint {
display: none;
}

.swipeAction {
display: none !important;
}
}
Loading