From 8a960259bccc5d3bd8810b842b8e53d4382f7af6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 12:16:35 +0000 Subject: [PATCH] feat: Implement Phase 1 & 2 of Spotify-inspired redesign Phase 1 - Bottom Tab Bar: - Add persistent bottom tab bar with Climbs, Search, and New tabs - Hidden on desktop via CSS media query (>=768px) - Search tab opens existing search drawer - Create tab opens bottom drawer with Create Climb and Playlists - iOS safe area padding support Phase 2 - Compact Climb List: - Add ClimbListItem component with compact Spotify-like row layout - Colorized V-grade display, thumbnail, name, quality, setter info - Swipe right to favorite, swipe left to add to queue - Ellipsis menu with full action drawer - View mode toggle (list/grid) with localStorage persistence - Defaults to list mode on mobile, grid on desktop Code quality fixes: - Fix Space orientation->direction bug in queue-list.tsx - Replace hardcoded colors (#fff, #888) with theme tokens throughout - Replace hardcoded spacing with theme token values - Fix QueueControlBar interface naming (QueueControlBarProps) - Remove unused board prop from QueueControlBar - Remove unused imports and vendor prefixes - Clean up dead code across queue components https://claude.ai/code/session_01SrNcg1JnQosgiCfsKLvchM --- .../[size_id]/[set_ids]/[angle]/layout.tsx | 6 +- .../app/components/board-page/climbs-list.tsx | 118 ++++++- .../bottom-tab-bar/bottom-tab-bar.module.css | 38 +++ .../bottom-tab-bar/bottom-tab-bar.tsx | 214 ++++++++++++ .../components/climb-card/climb-list-item.tsx | 318 ++++++++++++++++++ .../queue-control/queue-control-bar.tsx | 14 +- .../queue-control/queue-list-item.tsx | 5 +- .../components/queue-control/queue-list.tsx | 6 +- 8 files changed, 684 insertions(+), 35 deletions(-) create mode 100644 packages/web/app/components/bottom-tab-bar/bottom-tab-bar.module.css create mode 100644 packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx create mode 100644 packages/web/app/components/climb-card/climb-list-item.tsx diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx index 9f4697c9..78a7e0d5 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/layout.tsx @@ -16,6 +16,7 @@ import { PartyProvider } from '@/app/components/party-manager/party-context'; import { BoardSessionBridge } from '@/app/components/persistent-session'; import { Metadata } from 'next'; import BoardPageSkeleton from '@/app/components/board-page/board-page-skeleton'; +import BottomTabBar from '@/app/components/bottom-tab-bar/bottom-tab-bar'; // Helper to get board details for any board type function getBoardDetailsUniversal(parsedParams: ParsedBoardRouteParameters): BoardDetails { @@ -139,7 +140,7 @@ export default async function BoardLayout(props: PropsWithChildren - + + diff --git a/packages/web/app/components/board-page/climbs-list.tsx b/packages/web/app/components/board-page/climbs-list.tsx index fb83dce5..239a961f 100644 --- a/packages/web/app/components/board-page/climbs-list.tsx +++ b/packages/web/app/components/board-page/climbs-list.tsx @@ -1,12 +1,31 @@ 'use client'; -import React, { useEffect, useRef, useCallback } from 'react'; -import { Row, Col } from 'antd'; +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { Row, Col, Button, Flex } from 'antd'; +import { AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { track } from '@vercel/analytics'; import { Climb, ParsedBoardRouteParameters, BoardDetails } from '@/app/lib/types'; import { useQueueContext } from '../graphql-queue'; import ClimbCard from '../climb-card/climb-card'; +import ClimbListItem from '../climb-card/climb-list-item'; import { ClimbCardSkeleton } from './board-page-skeleton'; import { useSearchParams } from 'next/navigation'; +import { themeTokens } from '@/app/theme/theme-config'; + +type ViewMode = 'grid' | 'list'; + +const VIEW_MODE_STORAGE_KEY = 'climbListViewMode'; + +function getInitialViewMode(): ViewMode { + if (typeof window === 'undefined') return 'grid'; + try { + const stored = localStorage.getItem(VIEW_MODE_STORAGE_KEY); + if (stored === 'grid' || stored === 'list') return stored; + } catch { + // localStorage not available + } + // Default: list on mobile, grid on desktop + return window.matchMedia('(min-width: 768px)').matches ? 'grid' : 'list'; +} type ClimbsListProps = ParsedBoardRouteParameters & { boardDetails: BoardDetails; @@ -34,6 +53,17 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => { const searchParams = useSearchParams(); const page = searchParams.get('page'); + const [viewMode, setViewMode] = useState(getInitialViewMode); + + const handleViewModeChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + try { + localStorage.setItem(VIEW_MODE_STORAGE_KEY, mode); + } catch { + // localStorage not available + } + track('View Mode Changed', { mode }); + }, []); // Queue Context provider uses React Query infinite to fetch results, which can only happen clientside. // That data equals null at the start, so when its null we use the initialClimbs array which we @@ -131,40 +161,92 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => { }, [handleObserver]); return ( -
- - {climbs.map((climb, index) => ( - +
+ {/* View mode toggle */} + + + + )} + + {/* Search tab */} + + + {/* Create tab */} + +
+ + {/* Search Drawer */} + setIsSearchOpen(false)} + footer={} + styles={{ + body: { padding: `${themeTokens.spacing[3]}px ${themeTokens.spacing[4]}px ${themeTokens.spacing[4]}px` }, + footer: { padding: 0, border: 'none' }, + wrapper: { width: '90%' }, + }} + > + + + + {/* Create Drawer */} + setIsCreateOpen(false)} + styles={{ + wrapper: { height: 'auto' }, + body: { padding: `${themeTokens.spacing[2]}px 0` }, + }} + > + + {createClimbUrl && ( + setIsCreateOpen(false)} + style={{ textDecoration: 'none' }} + > + + + )} + {playlistsUrl && ( + setIsCreateOpen(false)} + style={{ textDecoration: 'none' }} + > + + + )} + + + + ); +} + +const BottomTabBar: React.FC = (props) => { + return ( + + + + ); +}; + +export default BottomTabBar; diff --git a/packages/web/app/components/climb-card/climb-list-item.tsx b/packages/web/app/components/climb-card/climb-list-item.tsx new file mode 100644 index 00000000..67c6b79f --- /dev/null +++ b/packages/web/app/components/climb-card/climb-list-item.tsx @@ -0,0 +1,318 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { Drawer, Button, Typography } from 'antd'; +import { MoreOutlined, HeartOutlined, HeartFilled, PlusOutlined } from '@ant-design/icons'; +import { useSwipeable } from 'react-swipeable'; +import { Climb, BoardDetails } from '@/app/lib/types'; +import ClimbThumbnail from './climb-thumbnail'; +import { AscentStatus } from '../queue-control/queue-list-item'; +import { ClimbActions } from '../climb-actions'; +import { useQueueContext } from '../graphql-queue'; +import { useFavorite } from '../climb-actions'; +import { themeTokens } from '@/app/theme/theme-config'; +import { getGradeColor } from '@/app/lib/grade-colors'; + +const { Text } = Typography; + +// Swipe threshold in pixels to trigger the swipe action +const SWIPE_THRESHOLD = 100; +// Maximum swipe distance +const MAX_SWIPE = 120; + +type ClimbListItemProps = { + climb: Climb; + boardDetails: BoardDetails; + selected?: boolean; + onSelect?: () => void; +}; + +const ClimbListItem: React.FC = React.memo(({ climb, boardDetails, selected, onSelect }) => { + const [swipeOffset, setSwipeOffset] = useState(0); + const [isHorizontalSwipe, setIsHorizontalSwipe] = useState(null); + const [isActionsOpen, setIsActionsOpen] = useState(false); + const { addToQueue } = useQueueContext(); + const { isFavorited, toggleFavorite } = useFavorite({ climbUuid: climb.uuid }); + + const handleSwipeLeft = useCallback(() => { + // Swipe left = add to queue + addToQueue(climb); + setSwipeOffset(0); + }, [climb, addToQueue]); + + const handleSwipeRight = useCallback(() => { + // Swipe right = toggle favorite + toggleFavorite(); + setSwipeOffset(0); + }, [toggleFavorite]); + + const swipeHandlers = useSwipeable({ + onSwiping: (eventData) => { + const { deltaX, deltaY, event } = eventData; + + // On first movement, determine if this is a horizontal or vertical swipe + if (isHorizontalSwipe === null) { + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + if (absX > 10 || absY > 10) { + setIsHorizontalSwipe(absX > absY); + } + return; + } + + // If it's a vertical swipe, don't interfere + if (!isHorizontalSwipe) return; + + // Horizontal swipe - prevent scroll and update offset + if ('nativeEvent' in event) { + event.nativeEvent.preventDefault(); + } else { + event.preventDefault(); + } + const clampedOffset = Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, deltaX)); + setSwipeOffset(clampedOffset); + }, + onSwipedLeft: (eventData) => { + if (isHorizontalSwipe && Math.abs(eventData.deltaX) >= SWIPE_THRESHOLD) { + handleSwipeLeft(); + } else { + setSwipeOffset(0); + } + setIsHorizontalSwipe(null); + }, + onSwipedRight: (eventData) => { + if (isHorizontalSwipe && Math.abs(eventData.deltaX) >= SWIPE_THRESHOLD) { + handleSwipeRight(); + } else { + setSwipeOffset(0); + } + setIsHorizontalSwipe(null); + }, + onTouchEndOrOnMouseUp: () => { + if (Math.abs(swipeOffset) < SWIPE_THRESHOLD) { + setSwipeOffset(0); + } + setIsHorizontalSwipe(null); + }, + trackMouse: false, + trackTouch: true, + preventScrollOnSwipe: false, + }); + + // Extract V grade for colorized display + const vGradeMatch = climb.difficulty?.match(/V\d+/i); + const vGrade = vGradeMatch ? vGradeMatch[0].toUpperCase() : null; + const gradeColor = getGradeColor(climb.difficulty); + const hasQuality = climb.quality_average && climb.quality_average !== '0'; + + // Swipe action visibility + const showLeftAction = swipeOffset > 0; // Swiping right reveals favorite on left + const showRightAction = swipeOffset < 0; // Swiping left reveals queue on right + const leftActionOpacity = Math.min(1, swipeOffset / SWIPE_THRESHOLD); + const rightActionOpacity = Math.min(1, Math.abs(swipeOffset) / SWIPE_THRESHOLD); + + // Build exclude list for moonboard + const excludeActions: ('tick' | 'openInApp' | 'mirror' | 'share' | 'addToList' | 'viewDetails')[] = []; + if (boardDetails.board_name === 'moonboard') { + excludeActions.push('viewDetails'); + } + + return ( + <> +
+ {/* Left action background (favorite - revealed on swipe right) */} +
+ {isFavorited ? ( + + ) : ( + + )} +
+ + {/* Right action background (add to queue - revealed on swipe left) */} +
+ +
+ + {/* Swipeable content */} +
+ {/* Thumbnail */} +
+ +
+ + {/* Center: Name, quality, setter */} +
+
+ + {climb.name} + + +
+ + {hasQuality ? `${climb.quality_average}\u2605` : ''}{' '} + {climb.setter_username && `${climb.setter_username}`} + +
+ + {/* Right: V-grade colorized */} + {vGrade && ( + + {vGrade} + + )} + {!vGrade && climb.difficulty && ( + + {climb.difficulty} + + )} + + {/* Ellipsis menu button */} +
+
+ + {/* Actions Drawer */} + +
+ +
+
+ + {climb.name} + + + {climb.difficulty} {hasQuality ? `${climb.quality_average}\u2605` : ''} + +
+
+ } + placement="bottom" + open={isActionsOpen} + onClose={() => setIsActionsOpen(false)} + styles={{ + wrapper: { height: 'auto' }, + body: { padding: `${themeTokens.spacing[2]}px 0` }, + }} + > + setIsActionsOpen(false)} + /> + + + ); +}, (prev, next) => { + return prev.climb.uuid === next.climb.uuid + && prev.selected === next.selected + && prev.boardDetails === next.boardDetails; +}); + +ClimbListItem.displayName = 'ClimbListItem'; + +export default ClimbListItem; diff --git a/packages/web/app/components/queue-control/queue-control-bar.tsx b/packages/web/app/components/queue-control/queue-control-bar.tsx index 6d1c8a8d..8deee480 100644 --- a/packages/web/app/components/queue-control/queue-control-bar.tsx +++ b/packages/web/app/components/queue-control/queue-control-bar.tsx @@ -10,9 +10,8 @@ import NextClimbButton from './next-climb-button'; import { usePathname, useParams, useSearchParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { constructPlayUrlWithSlugs, constructClimbViewUrlWithSlugs, parseBoardRouteParams } from '@/app/lib/url-utils'; -import { BoardRouteParameters, BoardRouteParametersWithUuid } from '@/app/lib/types'; +import { BoardRouteParameters, BoardDetails, Angle } from '@/app/lib/types'; import PreviousClimbButton from './previous-climb-button'; -import { BoardName, BoardDetails, Angle } from '@/app/lib/types'; import QueueList, { QueueListHandle } from './queue-list'; import { TickButton } from '../logbook/tick-button'; import ClimbThumbnail from '../climb-card/climb-thumbnail'; @@ -27,13 +26,12 @@ const SWIPE_THRESHOLD = 100; // Maximum swipe distance (matches queue-list-item) const MAX_SWIPE = 120; -export interface QueueControlBar { +export interface QueueControlBarProps { boardDetails: BoardDetails; - board: BoardName; angle: Angle; } -const QueueControlBar: React.FC = ({ boardDetails, angle }: QueueControlBar) => { +const QueueControlBar: React.FC = ({ boardDetails, angle }) => { const [isQueueOpen, setIsQueueOpen] = useState(false); const [swipeOffset, setSwipeOffset] = useState(0); const pathname = usePathname(); @@ -231,7 +229,7 @@ const QueueControlBar: React.FC = ({ boardDetails, angle }: Que const rightActionOpacity = Math.min(1, Math.abs(swipeOffset) / SWIPE_THRESHOLD); return ( -
+
{/* Main Control Bar */} = ({ boardDetails, angle }: Que {...swipeHandlers} className={styles.swipeContainer} style={{ - padding: '4px 12px 0px 12px', + padding: `${themeTokens.spacing[1]}px ${themeTokens.spacing[3]}px 0 ${themeTokens.spacing[3]}px`, transform: `translateX(${swipeOffset}px)`, transition: swipeOffset === 0 ? `transform ${themeTokens.transitions.fast}` : 'none', - backgroundColor: '#fff', + backgroundColor: themeTokens.semantic.surface, }} > diff --git a/packages/web/app/components/queue-control/queue-list-item.tsx b/packages/web/app/components/queue-control/queue-list-item.tsx index 0413e824..26bc58e2 100644 --- a/packages/web/app/components/queue-control/queue-list-item.tsx +++ b/packages/web/app/components/queue-control/queue-list-item.tsx @@ -309,7 +309,7 @@ const QueueListItem: React.FC = ({ style={{ display: 'flex', alignItems: 'center', - padding: '12px 8px', + padding: `${themeTokens.spacing[3]}px ${themeTokens.spacing[2]}px`, backgroundColor: isCurrent ? themeTokens.semantic.selected : isHistory @@ -318,9 +318,6 @@ const QueueListItem: React.FC = ({ opacity: isSwipeComplete ? 0 : isHistory ? 0.6 : 1, cursor: 'grab', position: 'relative', - WebkitUserSelect: 'none', - MozUserSelect: 'none', - msUserSelect: 'none', userSelect: 'none', borderLeft: isCurrent ? `3px solid ${themeTokens.colors.primary}` : undefined, transform: `translateX(${swipeOffset}px)`, diff --git a/packages/web/app/components/queue-control/queue-list.tsx b/packages/web/app/components/queue-control/queue-list.tsx index 49594be9..7a05c2d5 100644 --- a/packages/web/app/components/queue-control/queue-list.tsx +++ b/packages/web/app/components/queue-control/queue-list.tsx @@ -247,7 +247,7 @@ const QueueList = forwardRef(({ boardDetails, o style={{ display: 'flex', alignItems: 'center', - padding: '12px 8px', + padding: `${themeTokens.spacing[3]}px ${themeTokens.spacing[2]}px`, borderBottom: `1px solid ${themeTokens.neutral[200]}`, }} > @@ -326,8 +326,8 @@ const QueueList = forwardRef(({ boardDetails, o open={tickDrawerVisible} styles={{ wrapper: { height: '50%' } }} > - - Sign in to record ticks + + Sign in to record ticks Create a Boardsesh account to log your climbs and track your progress.