From a75a05bb544012d5653147c51c1ef3bc84a9b431 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 12:14:06 +0000 Subject: [PATCH] feat: Add bottom tab bar for mobile navigation (Phase 1) Add a persistent bottom tab bar with Climbs, Search, and New tabs that replaces mobile-specific buttons previously in the header. Search opens a right drawer with filters, New opens a bottom drawer with Create Climb and My Playlists options. The tab bar is hidden on desktop (>=768px) where sidebar handles these functions. - New BottomTabBar component with iOS safe area support - New CreateDrawer component for create/playlist actions - Remove mobile SearchButton from header (moved to tab bar) - Remove Create Climb from mobile meatball menu (moved to tab bar) - Mark Phase 1 as done in redesign plan https://claude.ai/code/session_01ReTs4AchTet4cCNkUDsgm1 --- docs/spotify-ui-redesign-plan.md | 14 +- .../[size_id]/[set_ids]/[angle]/layout.tsx | 2 + .../web/app/components/board-page/header.tsx | 20 +-- .../bottom-tab-bar/bottom-tab-bar.module.css | 36 ++++ .../bottom-tab-bar/bottom-tab-bar.tsx | 168 ++++++++++++++++++ .../create-drawer/create-drawer.tsx | 77 ++++++++ 6 files changed, 295 insertions(+), 22 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/create-drawer/create-drawer.tsx diff --git a/docs/spotify-ui-redesign-plan.md b/docs/spotify-ui-redesign-plan.md index 48b14996..7536414b 100644 --- a/docs/spotify-ui-redesign-plan.md +++ b/docs/spotify-ui-redesign-plan.md @@ -134,7 +134,7 @@ This plan transforms Boardsesh's UI into a Spotify-like experience with a persis --- -## Phase 1: Bottom Tab Bar +## Phase 1: Bottom Tab Bar ✅ DONE ### What changes Add a persistent bottom tab bar below the QueueControlBar with three tabs: **Home**, **Search**, and **Create**. @@ -896,15 +896,15 @@ layout.tsx (server component) ## Testing Checklist ### Phase 1 -- [ ] Bottom tab bar renders on mobile, hidden on desktop +- [x] Bottom tab bar renders on mobile, hidden on desktop - [ ] Home tab navigates to /list when feature flag is off (default) - [ ] Home tab navigates to /home when `NEXT_PUBLIC_ENABLE_HOME_SCREEN=true` - [ ] Home placeholder page renders without errors -- [ ] Search tab opens advanced search drawer (same as old header SearchButton) -- [ ] Create tab opens create drawer -- [ ] Create drawer links work (create climb, create playlist) -- [ ] Create drawer hides playlist option for MoonBoard -- [ ] iOS safe area padding works (`env(safe-area-inset-bottom)`) +- [x] Search tab opens advanced search drawer (same as old header SearchButton) +- [x] Create tab opens create drawer +- [x] Create drawer links work (create climb, create playlist) +- [x] Create drawer hides playlist option for MoonBoard +- [x] iOS safe area padding works (`env(safe-area-inset-bottom)`) - [ ] Tab bar does not overlap QueueControlBar - [ ] Content area scrolling is not blocked by tab bar - [ ] Tab bar shows on play/view pages, hides on create page 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..289473b4 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 { @@ -172,6 +173,7 @@ export default async function BoardLayout(props: PropsWithChildren + diff --git a/packages/web/app/components/board-page/header.tsx b/packages/web/app/components/board-page/header.tsx index 6dbfa994..d27c583b 100644 --- a/packages/web/app/components/board-page/header.tsx +++ b/packages/web/app/components/board-page/header.tsx @@ -5,7 +5,6 @@ import { Flex, Button, Dropdown, MenuProps } from 'antd'; import { Header } from 'antd/es/layout/layout'; import { useSession, signOut } from 'next-auth/react'; import { usePathname, useSearchParams, useRouter } from 'next/navigation'; -import SearchButton from '../search-drawer/search-button'; import SearchClimbNameInput from '../search-drawer/search-climb-name-input'; import { UISearchParamsProvider } from '../queue-control/ui-searchparams-provider'; import { BoardDetails } from '@/app/lib/types'; @@ -155,11 +154,6 @@ export default function BoardSeshHeader({ boardDetails, angle }: BoardSeshHeader ]; const mobileMenuItems: MenuProps['items'] = [ - ...(createClimbUrl ? [{ - key: 'create-climb', - icon: , - label: Create Climb, - }] : []), ...(session?.user && playlistsUrl && !isMoonboard ? [{ key: 'playlists', icon: , @@ -256,16 +250,12 @@ export default function BoardSeshHeader({ boardDetails, angle }: BoardSeshHeader {/* Center Section - Content varies by page mode */} - {/* List page: Show search (mobile only) */} + {/* List page: Show search name input (mobile only) */} + {/* Full search filters moved to bottom tab bar on mobile */} {pageMode === 'list' && ( - <> -
- -
-
- -
- +
+ +
)} {/* View page: Empty center on mobile (back button is in the page content) */} diff --git a/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.module.css b/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.module.css new file mode 100644 index 00000000..d41a5f9d --- /dev/null +++ b/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.module.css @@ -0,0 +1,36 @@ +.tabBar { + display: flex; + justify-content: space-around; + align-items: center; + width: 100%; + background: #fff; + border-top: 1px solid #E5E7EB; + padding-bottom: env(safe-area-inset-bottom, 0px); + touch-action: manipulation; +} + +.tabItem { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + padding: 6px 0 4px; + cursor: pointer; + border: none; + background: none; + gap: 2px; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +.tabLabel { + font-size: 11px; + line-height: 1; +} + +@media (min-width: 768px) { + .tabBar { + display: none; + } +} diff --git a/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx b/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx new file mode 100644 index 00000000..81e612c1 --- /dev/null +++ b/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx @@ -0,0 +1,168 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { Drawer, Spin, Typography } from 'antd'; +import { UnorderedListOutlined, SearchOutlined, PlusOutlined } from '@ant-design/icons'; +import { usePathname } from 'next/navigation'; +import { BoardDetails } from '@/app/lib/types'; +import { themeTokens } from '@/app/theme/theme-config'; +import { useQueueContext } from '../graphql-queue'; +import { UISearchParamsProvider, useUISearchParams } from '../queue-control/ui-searchparams-provider'; +import { DEFAULT_SEARCH_PARAMS } from '@/app/lib/url-utils'; +import SearchForm from '../search-drawer/search-form'; +import ClearButton from '../search-drawer/clear-button'; +import CreateDrawer from '../create-drawer/create-drawer'; +import styles from './bottom-tab-bar.module.css'; + +const { Text } = Typography; + +interface BottomTabBarProps { + boardDetails: BoardDetails; +} + +const SearchDrawerFooter: React.FC = () => { + const { totalSearchResultCount, isFetchingClimbs } = useQueueContext(); + const { uiSearchParams } = useUISearchParams(); + + const hasActiveFilters = Object.entries(uiSearchParams).some(([key, value]) => { + if (key === 'holdsFilter') { + return Object.keys(value || {}).length > 0; + } + return value !== DEFAULT_SEARCH_PARAMS[key as keyof typeof DEFAULT_SEARCH_PARAMS]; + }); + + if (!hasActiveFilters) return null; + + return ( +
+
+ {isFetchingClimbs ? ( + + ) : ( + + + {(totalSearchResultCount ?? 0).toLocaleString()} + {' '} + results + + )} +
+ +
+ ); +}; + +const SearchDrawerContent: React.FC<{ boardDetails: BoardDetails; open: boolean; onClose: () => void }> = ({ + boardDetails, + open, + onClose, +}) => { + return ( + + } + styles={{ + body: { padding: `${themeTokens.spacing[3]}px ${themeTokens.spacing[4]}px ${themeTokens.spacing[4]}px` }, + footer: { padding: 0, border: 'none' }, + wrapper: { width: '90%' }, + }} + > + + + + ); +}; + +const BottomTabBar: React.FC = ({ boardDetails }) => { + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const pathname = usePathname(); + + const activeTab = useMemo((): string => { + if (pathname.includes('/list')) return 'climbs'; + return 'climbs'; + }, [pathname]); + + const tabs: { key: string; label: string; icon: React.ReactNode; onClick: () => void }[] = [ + { + key: 'climbs', + label: 'Climbs', + icon: , + onClick: () => { + // Already on climbs list - no-op + }, + }, + { + key: 'search', + label: 'Search', + icon: , + onClick: () => setIsSearchOpen(true), + }, + { + key: 'create', + label: 'New', + icon: , + onClick: () => setIsCreateOpen(true), + }, + ]; + + return ( + <> +
+ {tabs.map((tab) => { + const isActive = tab.key === activeTab; + const color = isActive ? themeTokens.colors.primary : themeTokens.neutral[400]; + return ( + + ); + })} +
+ + setIsSearchOpen(false)} + /> + + setIsCreateOpen(false)} + /> + + ); +}; + +export default BottomTabBar; diff --git a/packages/web/app/components/create-drawer/create-drawer.tsx b/packages/web/app/components/create-drawer/create-drawer.tsx new file mode 100644 index 00000000..ac0c2238 --- /dev/null +++ b/packages/web/app/components/create-drawer/create-drawer.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React from 'react'; +import { Drawer, List } from 'antd'; +import { EditOutlined, TagOutlined } from '@ant-design/icons'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { BoardDetails, BoardRouteParameters } from '@/app/lib/types'; +import { generateLayoutSlug, generateSizeSlug, generateSetSlug } from '@/app/lib/url-utils'; +import { themeTokens } from '@/app/theme/theme-config'; + +interface CreateDrawerProps { + boardDetails: BoardDetails; + open: boolean; + onClose: () => void; +} + +const CreateDrawer: React.FC = ({ boardDetails, open, onClose }) => { + const params = useParams(); + + const canBuildSlugUrls = boardDetails.layout_name && boardDetails.size_name && boardDetails.set_names; + const isMoonboard = boardDetails.board_name === 'moonboard'; + + const buildUrl = (suffix: string) => { + if (canBuildSlugUrls) { + return `/${boardDetails.board_name}/${generateLayoutSlug(boardDetails.layout_name!)}/${generateSizeSlug(boardDetails.size_name!, boardDetails.size_description)}/${generateSetSlug(boardDetails.set_names!)}/${params.angle}/${suffix}`; + } + return `/${params.board_name}/${params.layout_id}/${params.size_id}/${params.set_ids}/${params.angle}/${suffix}`; + }; + + const items = [ + { + key: 'create-climb', + icon: , + title: 'Create Climb', + description: 'Set holds and publish a new climb', + href: buildUrl('create'), + }, + ...(!isMoonboard ? [{ + key: 'playlists', + icon: , + title: 'My Playlists', + description: 'View and manage your playlists', + href: buildUrl('playlists'), + }] : []), + ]; + + return ( + + ( + + + + + + )} + /> + + ); +}; + +export default CreateDrawer;