diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/play-view-client.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/play-view-client.tsx index d25417af..0e8314f2 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/play-view-client.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/play-view-client.tsx @@ -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'; @@ -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 = ({ boardDetails, initialClimb, angle }) => { const router = useRouter(); @@ -36,19 +37,44 @@ const PlayViewClient: React.FC = ({ 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; @@ -125,31 +151,29 @@ const PlayViewClient: React.FC = ({ 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(); @@ -173,48 +197,61 @@ const PlayViewClient: React.FC = ({ boardDetails, initialCl {/* Main Content with Swipe */}
{/* Climb title - horizontal layout with grade on right */} -
+
-
- {/* Swipe indicators */} - {prevItem && ( +
+ {/* Left action background (previous - revealed on swipe right) */} + {prevItem && isClient && (
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', + }} > - +
)} - {nextItem && ( + + {/* Right action background (next - revealed on swipe left) */} + {nextItem && isClient && (
- +
)} -
- -
+ {/* Swipeable content */} +
+
+ +
- {/* Swipe hint for mobile */} - {showSwipeHint && queue.length > 1 && ( -
Swipe left/right to navigate
- )} + {/* Swipe hint for mobile */} + {showSwipeHint && queue.length > 1 && ( +
Swipe left/right to navigate
+ )} +
diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/play-view.module.css b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/play-view.module.css index 5d0500c8..cd07afbe 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/play-view.module.css +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/play-view.module.css @@ -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 { @@ -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 { @@ -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 { @@ -106,4 +120,8 @@ .swipeHint { display: none; } + + .swipeAction { + display: none !important; + } }