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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ParsedBoardRouteParameters, BoardRouteParameters, BoardDetails } from '
import { parseBoardRouteParams, constructClimbListWithSlugs } from '@/app/lib/url-utils';
import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server';
import { permanentRedirect } from 'next/navigation';
import { Content } from 'antd/es/layout/layout';
import QueueControlBar from '@/app/components/queue-control/queue-control-bar';
import { getBoardDetails } from '@/app/lib/data/queries';
import BoardSeshHeader from '@/app/components/board-page/header';
Expand All @@ -14,6 +13,7 @@ import { ConnectionSettingsProvider } from '@/app/components/connection-manager/
import { PartyProvider } from '@/app/components/party-manager/party-context';
import PartyProfileWrapper from '@/app/components/party-manager/party-profile-wrapper';
import { Metadata } from 'next';
import { ScrollableContent } from '@/app/components/board-page/scrollable-content';

/**
* Generates a user-friendly page title from board details.
Expand Down Expand Up @@ -139,21 +139,9 @@ export default async function BoardLayout(props: PropsWithChildren<BoardLayoutPr
<PartyProvider>
<BoardSeshHeader boardDetails={boardDetails} angle={angle} />

<Content
id="content-for-scrollable"
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
overflowY: 'auto',
overflowX: 'hidden',
height: '80vh',
paddingLeft: '10px',
paddingRight: '10px',
}}
>
<ScrollableContent>
{children}
</Content>
</ScrollableContent>

<Affix offsetBottom={0}>
<QueueControlBar board={board_name} boardDetails={boardDetails} angle={angle} />
Expand Down
37 changes: 29 additions & 8 deletions packages/web/app/components/board-page/climbs-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ClimbCard from '../climb-card/climb-card';
import { useEffect, useRef } from 'react';
import { PlusCircleOutlined, FireOutlined } from '@ant-design/icons';
import { useSearchParams } from 'next/navigation';
import { useScrollContainer } from './scrollable-content';

type ClimbsListProps = ParsedBoardRouteParameters & {
boardDetails: BoardDetails;
Expand Down Expand Up @@ -48,6 +49,9 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => {
const searchParams = useSearchParams();
const page = searchParams.get('page');

// Get scroll container ref from context - needed for InfiniteScroll to work properly
const scrollContainer = useScrollContainer();

// Queue Context provider uses SWR 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
// fill on the server side in the page component. This way the user never sees a loading state for
Expand Down Expand Up @@ -117,14 +121,32 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => {
}, []);

useEffect(() => {
if (page === '0' && hasDoneFirstFetch && isFetchingClimbs) {
const scrollContainer = document.getElementById('content-for-scrollable');
if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'instant' });
}
if (page === '0' && hasDoneFirstFetch && isFetchingClimbs && scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'instant' });
climbsRefs.current = {};
}
}, [page, hasDoneFirstFetch, isFetchingClimbs]); // Depend on the page query parameter
}, [page, hasDoneFirstFetch, isFetchingClimbs, scrollContainer]);

// Don't render InfiniteScroll until we have the scroll container ref
if (!scrollContainer) {
return (
<Row gutter={[16, 16]} style={{ paddingTop: '5px' }}>
{initialClimbs.map((climb) => (
<Col xs={24} lg={12} xl={12} id={climb.uuid} key={climb.uuid}>
<ClimbCard
climb={climb}
boardDetails={boardDetails}
selected={currentClimb?.uuid === climb.uuid}
onCoverClick={() => {
updateHash(climb.uuid);
setCurrentClimb(climb);
}}
/>
</Col>
))}
</Row>
);
}

return (
<InfiniteScroll
Expand All @@ -139,8 +161,7 @@ const ClimbsList = ({ boardDetails, initialClimbs }: ClimbsListProps) => {
hasMore={hasMoreResults}
loader={<Skeleton active />}
endMessage={<div style={{ textAlign: 'center' }}>No more climbs 🤐</div>}
// Probably not how this should be done in a React app, but it works and I ain't no CSS-wizard
scrollableTarget="content-for-scrollable"
scrollableTarget={scrollContainer}
style={{ paddingTop: '5px' }}
>
<Row gutter={[16, 16]}>
Expand Down
47 changes: 47 additions & 0 deletions packages/web/app/components/board-page/scrollable-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { Content } from 'antd/es/layout/layout';

type ScrollContainerContextType = HTMLDivElement | null;

const ScrollContainerContext = createContext<ScrollContainerContextType>(null);

export function useScrollContainer(): HTMLDivElement | null {
return useContext(ScrollContainerContext);
}

interface ScrollableContentProps {
children: ReactNode;
}

export function ScrollableContent({ children }: ScrollableContentProps) {
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);

const scrollContainerRef = useCallback((node: HTMLDivElement | null) => {
if (node !== null) {
setScrollContainer(node);
}
}, []);

return (
<ScrollContainerContext.Provider value={scrollContainer}>
<Content
ref={scrollContainerRef}
id="content-for-scrollable"
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
overflowY: 'auto',
overflowX: 'hidden',
height: '80vh',
paddingLeft: '10px',
paddingRight: '10px',
}}
>
{children}
</Content>
</ScrollContainerContext.Provider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const mockQueue: ClimbQueue = [];

describe('useQueueDataFetching', () => {
const mockSetHasDoneFirstFetch = vi.fn();
const mockGetLogbook = vi.fn();
const mockGetLogbook = vi.fn().mockResolvedValue([]);
const mockSetSize = vi.fn();

beforeEach(() => {
Expand Down Expand Up @@ -252,7 +252,8 @@ describe('useQueueDataFetching', () => {
expect(result.current.climbSearchResults).toBeNull();
expect(result.current.suggestedClimbs).toEqual([]);
expect(result.current.totalSearchResultCount).toBeNull();
expect(result.current.hasMoreResults).toBeFalsy();
// Before first fetch completes, we optimistically assume there are more results
expect(result.current.hasMoreResults).toBe(true);
});

it('should handle fetchMoreClimbs', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ export const useQueueDataFetching = ({
initialSize: searchParams.page ? searchParams.page + 1 : 1,
});

const hasMoreResults = data && data[0] && size * PAGE_LIMIT < data[0].totalCount;
// Before first fetch, assume there are more results (optimistic)
// After fetch, use actual totalCount from response
const hasMoreResults = data === undefined
? true
: !!(data && data[0] && size * PAGE_LIMIT < data[0].totalCount);
const totalSearchResultCount = (data && data[0] && data[0].totalCount) || null;

const climbSearchResults = useMemo(
Expand All @@ -87,10 +91,11 @@ export const useQueueDataFetching = ({
return; // Skip if we've already fetched these exact UUIDs
}

console.log('Fetching logbook for UUIDs:', climbUuidsString); // Debug log
const climbUuids = JSON.parse(climbUuidsString);
if (climbUuids.length > 0) {
getLogbook(climbUuids);
getLogbook(climbUuids).catch(() => {
// Error is already handled in getLogbook, just prevent unhandled rejection
});
fetchedUuidsRef.current = climbUuidsString;
}
}, [climbUuidsString, getLogbook]);
Expand Down
Loading