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
14 changes: 7 additions & 7 deletions docs/spotify-ui-redesign-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -172,6 +173,7 @@ export default async function BoardLayout(props: PropsWithChildren<BoardLayoutPr

<Affix offsetBottom={0}>
<QueueControlBar board={board_name} boardDetails={boardDetails} angle={angle} />
<BottomTabBar boardDetails={boardDetails} />
</Affix>
</PartyProvider>
</GraphQLQueueProvider>
Expand Down
20 changes: 5 additions & 15 deletions packages/web/app/components/board-page/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -155,11 +154,6 @@ export default function BoardSeshHeader({ boardDetails, angle }: BoardSeshHeader
];

const mobileMenuItems: MenuProps['items'] = [
...(createClimbUrl ? [{
key: 'create-climb',
icon: <PlusOutlined />,
label: <Link href={createClimbUrl}>Create Climb</Link>,
}] : []),
...(session?.user && playlistsUrl && !isMoonboard ? [{
key: 'playlists',
icon: <TagOutlined />,
Expand Down Expand Up @@ -256,16 +250,12 @@ export default function BoardSeshHeader({ boardDetails, angle }: BoardSeshHeader

{/* Center Section - Content varies by page mode */}
<Flex justify="center" gap={2} style={{ flex: 1 }} align="center">
{/* 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' && (
<>
<div className={styles.mobileOnly} style={{ flex: 1 }}>
<SearchClimbNameInput />
</div>
<div className={styles.mobileOnly}>
<SearchButton boardDetails={boardDetails} />
</div>
</>
<div className={styles.mobileOnly} style={{ flex: 1 }}>
<SearchClimbNameInput />
</div>
)}

{/* View page: Empty center on mobile (back button is in the page content) */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
168 changes: 168 additions & 0 deletions packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: `${themeTokens.spacing[2]}px ${themeTokens.spacing[4]}px`,
}}>
<div>
{isFetchingClimbs ? (
<Spin size="small" />
) : (
<Text type="secondary">
<span style={{ fontWeight: themeTokens.typography.fontWeight.semibold }}>
{(totalSearchResultCount ?? 0).toLocaleString()}
</span>{' '}
results
</Text>
)}
</div>
<ClearButton />
</div>
);
};

const SearchDrawerContent: React.FC<{ boardDetails: BoardDetails; open: boolean; onClose: () => void }> = ({
boardDetails,
open,
onClose,
}) => {
return (
<UISearchParamsProvider>
<Drawer
title="Search Climbs"
placement="right"
size="large"
open={open}
onClose={onClose}
footer={<SearchDrawerFooter />}
styles={{
body: { padding: `${themeTokens.spacing[3]}px ${themeTokens.spacing[4]}px ${themeTokens.spacing[4]}px` },
footer: { padding: 0, border: 'none' },
wrapper: { width: '90%' },
}}
>
<SearchForm boardDetails={boardDetails} />
</Drawer>
</UISearchParamsProvider>
);
};

const BottomTabBar: React.FC<BottomTabBarProps> = ({ 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: <UnorderedListOutlined />,
onClick: () => {
// Already on climbs list - no-op
},
},
{
key: 'search',
label: 'Search',
icon: <SearchOutlined />,
onClick: () => setIsSearchOpen(true),
},
{
key: 'create',
label: 'New',
icon: <PlusOutlined />,
onClick: () => setIsCreateOpen(true),
},
];

return (
<>
<div className={styles.tabBar} role="tablist" aria-label="Navigation">
{tabs.map((tab) => {
const isActive = tab.key === activeTab;
const color = isActive ? themeTokens.colors.primary : themeTokens.neutral[400];
return (
<button
key={tab.key}
className={styles.tabItem}
role="tab"
aria-label={tab.label}
aria-selected={isActive}
onClick={tab.onClick}
style={{ color }}
>
<span style={{
fontSize: themeTokens.typography.fontSize.xl,
lineHeight: 1,
transition: `color ${themeTokens.transitions.fast}`,
}}>
{tab.icon}
</span>
<span
className={styles.tabLabel}
style={{ transition: `color ${themeTokens.transitions.fast}` }}
>
{tab.label}
</span>
</button>
);
})}
</div>

<SearchDrawerContent
boardDetails={boardDetails}
open={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
/>

<CreateDrawer
boardDetails={boardDetails}
open={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
/>
</>
);
};

export default BottomTabBar;
77 changes: 77 additions & 0 deletions packages/web/app/components/create-drawer/create-drawer.tsx
Original file line number Diff line number Diff line change
@@ -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<CreateDrawerProps> = ({ boardDetails, open, onClose }) => {
const params = useParams<BoardRouteParameters>();

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: <EditOutlined style={{ fontSize: themeTokens.typography.fontSize.xl, color: themeTokens.colors.primary }} />,
title: 'Create Climb',
description: 'Set holds and publish a new climb',
href: buildUrl('create'),
},
...(!isMoonboard ? [{
key: 'playlists',
icon: <TagOutlined style={{ fontSize: themeTokens.typography.fontSize.xl, color: themeTokens.colors.primary }} />,
title: 'My Playlists',
description: 'View and manage your playlists',
href: buildUrl('playlists'),
}] : []),
];

return (
<Drawer
title="Create"
placement="bottom"
open={open}
onClose={onClose}
height="auto"
styles={{
body: { padding: '0 0 env(safe-area-inset-bottom, 0px)' },
}}
>
<List
dataSource={items}
renderItem={(item) => (
<Link href={item.href} onClick={onClose}>
<List.Item style={{ padding: `${themeTokens.spacing[4]}px ${themeTokens.spacing[6]}px`, cursor: 'pointer' }}>
<List.Item.Meta
avatar={item.icon}
title={item.title}
description={item.description}
/>
</List.Item>
</Link>
)}
/>
</Drawer>
);
};

export default CreateDrawer;
Loading