From 2834f23b1112bb1c58dedbfbbe5a68d0d8623cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 12:41:51 +0000 Subject: [PATCH 1/3] feat: Update play view swipe to match queue control bar style Replace circular swipe indicators with blue square action backgrounds that slide in from the edges, matching the existing queue control bar swipe experience. Uses FastForwardOutlined/FastBackwardOutlined icons and the primary theme color for the action backgrounds. --- .../play/[climb_uuid]/play-view-client.tsx | 108 ++++++++++++------ .../play/[climb_uuid]/play-view.module.css | 46 ++++---- 2 files changed, 93 insertions(+), 61 deletions(-) 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..e5130ca0 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(); @@ -125,31 +126,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); + setShowSwipeHint(false); }, 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(); @@ -181,40 +180,79 @@ const PlayViewClient: React.FC = ({ boardDetails, initialCl > -
- {/* Swipe indicators */} +
+ {/* Left action background (previous - revealed on swipe right) */} {prevItem && (
SWIPE_THRESHOLD / 2 ? styles.swipeIndicatorVisible : '' - }`} + className={styles.swipeAction} + style={{ + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: MAX_SWIPE, + backgroundColor: themeTokens.colors.primary, + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + paddingLeft: themeTokens.spacing[4], + opacity: Math.min(1, swipeOffset / SWIPE_THRESHOLD), + visibility: swipeOffset > 0 ? 'visible' : 'hidden', + zIndex: 0, + }} > - +
)} + + {/* Right action background (next - revealed on swipe left) */} {nextItem && (
- +
)} -
- -
+ {/* 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..3476bf11 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 @@ -22,6 +22,15 @@ flex-shrink: 0; } +.swipeWrapper { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + min-height: 0; +} + .swipeContainer { flex: 1; display: flex; @@ -31,6 +40,13 @@ 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 { + z-index: 0; } .boardContainer { @@ -70,32 +86,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 +96,8 @@ .swipeHint { display: none; } + + .swipeAction { + display: none !important; + } } From f7db37ac622a048773d9af4d5c06747bde791180 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 12:50:35 +0000 Subject: [PATCH 2/3] fix: Persist swipe hint dismissal in localStorage Once a user swipes in the play view, the hint is permanently dismissed and never shown again on subsequent visits. --- .../play/[climb_uuid]/play-view-client.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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 e5130ca0..c250502d 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 @@ -37,19 +37,34 @@ const PlayViewClient: React.FC = ({ boardDetails, initialCl } = useQueueContext(); const [swipeOffset, setSwipeOffset] = useState(0); - const [showSwipeHint, setShowSwipeHint] = useState(true); + const [showSwipeHint, setShowSwipeHint] = 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) useEffect(() => { + const hasSwipedBefore = localStorage.getItem('playViewSwipeHintDismissed'); + if (hasSwipedBefore) { + setShowSwipeHint(false); + return; + } + + // Show hint for new users, then auto-hide after 3 seconds + setShowSwipeHint(true); const timer = setTimeout(() => { setShowSwipeHint(false); }, 3000); return () => clearTimeout(timer); }, []); + const dismissSwipeHintPermanently = useCallback(() => { + if (showSwipeHint) { + setShowSwipeHint(false); + localStorage.setItem('playViewSwipeHintDismissed', 'true'); + } + }, [showSwipeHint]); + const getBackToListUrl = useCallback(() => { const { board_name, layout_name, size_name, size_description, set_names } = boardDetails; @@ -129,7 +144,7 @@ const PlayViewClient: React.FC = ({ boardDetails, initialCl // Clamp the offset within bounds (matches queue-control-bar) const clampedOffset = Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, deltaX)); setSwipeOffset(clampedOffset); - setShowSwipeHint(false); + dismissSwipeHintPermanently(); }, onSwipedLeft: (eventData) => { setSwipeOffset(0); From 03028f95e277a423f6123f0dc7fda01b4c2c11db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 20:32:57 +0000 Subject: [PATCH 3/3] fix: Address SSR hydration, localStorage error handling, and inline styles - Wrap localStorage access in try-catch for private browsing compatibility - Add isClient state to prevent SSR hydration mismatch for swipe elements - Move static swipe action styles from inline to CSS module classes - Remove unnecessary inline styles (padding, positioning, etc.) --- .../play/[climb_uuid]/play-view-client.tsx | 76 ++++++++----------- .../play/[climb_uuid]/play-view.module.css | 24 ++++++ 2 files changed, 54 insertions(+), 46 deletions(-) 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 c250502d..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 @@ -38,30 +38,40 @@ const PlayViewClient: React.FC = ({ boardDetails, initialCl const [swipeOffset, setSwipeOffset] = useState(0); 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; // Check if user has already swiped before (persisted in localStorage) + // Only runs on client to avoid SSR hydration mismatch useEffect(() => { - const hasSwipedBefore = localStorage.getItem('playViewSwipeHintDismissed'); - if (hasSwipedBefore) { - setShowSwipeHint(false); - return; - } + 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); + // 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); - localStorage.setItem('playViewSwipeHintDismissed', 'true'); + try { + localStorage.setItem('playViewSwipeHintDismissed', 'true'); + } catch { + // localStorage unavailable (private browsing, quota exceeded, etc.) + } } }, [showSwipeHint]); @@ -187,60 +197,35 @@ const PlayViewClient: React.FC = ({ boardDetails, initialCl {/* Main Content with Swipe */}
{/* Climb title - horizontal layout with grade on right */} -
+
{/* Left action background (previous - revealed on swipe right) */} - {prevItem && ( + {prevItem && isClient && (
0 ? 'visible' : 'hidden', - zIndex: 0, }} > - +
)} {/* Right action background (next - revealed on swipe left) */} - {nextItem && ( + {nextItem && isClient && (
- +
)} @@ -249,9 +234,8 @@ const PlayViewClient: React.FC = ({ boardDetails, initialCl {...swipeHandlers} className={styles.swipeContainer} style={{ - transform: `translateX(${swipeOffset}px)`, + transform: isClient ? `translateX(${swipeOffset}px)` : undefined, transition: swipeOffset === 0 ? `transform ${themeTokens.transitions.fast}` : 'none', - backgroundColor: themeTokens.semantic.background, }} >
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 3476bf11..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,7 @@ .climbTitleContainer { width: 100%; flex-shrink: 0; + padding: 4px 12px; } .swipeWrapper { @@ -46,9 +47,32 @@ /* 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 { flex: 1; display: flex;