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 @@ -6,6 +6,7 @@ import { getClimb } from '@/app/lib/data/queries';
import ClimbCard from '@/app/components/climb-card/climb-card';
import { Col, Row } from 'antd';
import BetaVideos from '@/app/components/beta-videos/beta-videos';
import SimilarClimbs from '@/app/components/similar-climbs/similar-climbs';
import {
constructClimbInfoUrl,
extractUuidFromSlug,
Expand Down Expand Up @@ -206,6 +207,9 @@ export default async function DynamicResultsPage(props: { params: Promise<BoardR
</Col>
<Col xs={24} lg={8}>
<BetaVideos betaLinks={betaLinks} />
<div style={{ marginTop: '16px' }}>
<SimilarClimbs params={parsedParams} boardDetails={boardDetails} />
</div>
</Col>
</Row>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { getSimilarClimbs, SimilarClimbsResult } from '@/app/lib/db/queries/climbs/similar-climbs';
import { BoardRouteParametersWithUuid, ErrorResponse } from '@/app/lib/types';
import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server';
import { NextResponse } from 'next/server';

export async function GET(
req: Request,
props: { params: Promise<BoardRouteParametersWithUuid> },
): Promise<NextResponse<SimilarClimbsResult | ErrorResponse>> {
const params = await props.params;

try {
const parsedParams = await parseBoardRouteParamsWithSlugs(params);

// Get similarity threshold from query params (default 0.9)
const url = new URL(req.url);
const threshold = parseFloat(url.searchParams.get('threshold') || '0.9');
const limit = parseInt(url.searchParams.get('limit') || '10', 10);

const result = await getSimilarClimbs(parsedParams, threshold, limit);

return NextResponse.json(result);
} catch (error) {
console.error('Error fetching similar climbs:', error);
return NextResponse.json({ error: 'Failed to fetch similar climbs' }, { status: 500 });
}
}
42 changes: 42 additions & 0 deletions app/components/rest-api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SearchClimbsResult,
} from '@/app/lib/types';
import { BetaLink } from '@/app/lib/api-wrappers/sync-api-types';
import { LitUpHoldsMap } from '@/app/components/board-renderer/types';

const API_BASE_URL = `/api/v1`;

Expand Down Expand Up @@ -128,3 +129,44 @@ export const fetchSets = async (board_name: BoardName, layout_id: LayoutId, size

return response.json();
};

// Fetch similar climbs
export const fetchSimilarClimbs = async (
routeParameters: ParsedBoardRouteParametersWithUuid,
threshold: number = 0.9,
limit: number = 10,
): Promise<SimilarClimbsResult> => {
const apiUrl = `${API_BASE_URL}/${routeParameters.board_name}/${routeParameters.layout_id}/${routeParameters.size_id}/${routeParameters.set_ids}/${routeParameters.angle}/${routeParameters.climb_uuid}/similar?threshold=${threshold}&limit=${limit}`;
const response = await fetch(apiUrl);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

return response.json();
};

export type SimilarClimbsResult = {
exactLargerMatches: SimilarClimbMatch[];
highSimilarityMatches: SimilarClimbMatch[];
};

export type SimilarClimbMatch = {
uuid: string;
setter_username: string;
name: string;
description: string;
frames: string;
angle: number;
ascensionist_count: number;
difficulty: string;
quality_average: string;
stars: number;
difficulty_error: string;
benchmark_difficulty: string | null;
litUpHoldsMap: LitUpHoldsMap;
matchType: 'exact_larger' | 'high_similarity';
similarity: number;
matchingSizeId: number;
matchingSizeName: string;
};
165 changes: 165 additions & 0 deletions app/components/similar-climbs/similar-climbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
'use client';

import React from 'react';
import { List, Typography, Tag, Empty, Spin, Card } from 'antd';
import { CopyrightOutlined } from '@ant-design/icons';
import useSWR from 'swr';
import Link from 'next/link';
import { BoardDetails, ParsedBoardRouteParametersWithUuid } from '@/app/lib/types';
import { fetchSimilarClimbs, SimilarClimbMatch } from '../rest-api/api';
import BoardRenderer from '../board-renderer/board-renderer';
import { constructClimbViewUrlWithSlugs, parseBoardRouteParams, constructClimbViewUrl } from '@/app/lib/url-utils';

const { Text, Title } = Typography;

type SimilarClimbsProps = {
params: ParsedBoardRouteParametersWithUuid;
boardDetails: BoardDetails;
};

const SimilarClimbItem: React.FC<{
climb: SimilarClimbMatch;
boardDetails: BoardDetails;
params: ParsedBoardRouteParametersWithUuid;
}> = ({ climb, boardDetails, params }) => {
// Build the URL for the similar climb
const climbViewUrl =
boardDetails.layout_name && boardDetails.size_name && boardDetails.set_names
? constructClimbViewUrlWithSlugs(
boardDetails.board_name,
boardDetails.layout_name,
climb.matchingSizeName || boardDetails.size_name,
boardDetails.size_description,
boardDetails.set_names,
climb.angle,
climb.uuid,
climb.name,
)
: (() => {
const routeParams = parseBoardRouteParams({
board_name: boardDetails.board_name,
layout_id: boardDetails.layout_id.toString(),
size_id: (climb.matchingSizeId || boardDetails.size_id).toString(),
set_ids: boardDetails.set_ids.join(','),
angle: climb.angle.toString(),
});
return constructClimbViewUrl(routeParams, climb.uuid, climb.name);
})();

return (
<List.Item>
<div style={{ display: 'flex', gap: '12px', width: '100%' }}>
<div style={{ width: '80px', flexShrink: 0 }}>
<Link href={climbViewUrl}>
<BoardRenderer
litUpHoldsMap={climb.litUpHoldsMap}
mirrored={false}
boardDetails={boardDetails}
thumbnail
/>
</Link>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Link href={climbViewUrl} style={{ color: 'inherit', textDecoration: 'none' }}>
<Text strong style={{ display: 'block' }}>
{climb.name}
{climb.benchmark_difficulty && <CopyrightOutlined style={{ marginLeft: 4 }} />}
</Text>
</Link>
<Text type="secondary" style={{ display: 'block' }}>
{climb.difficulty && climb.quality_average !== '0'
? `${climb.difficulty} ${climb.quality_average}★`
: 'project'}{' '}
@ {climb.angle}°
</Text>
<Text type="secondary" style={{ display: 'block', fontSize: '12px' }}>
by {climb.setter_username} • {climb.ascensionist_count} ascents
</Text>
<div style={{ marginTop: '4px' }}>
{climb.matchType === 'exact_larger' ? (
<Tag color="green">Same climb on {climb.matchingSizeName}</Tag>
) : (
<Tag color="blue">{Math.round(climb.similarity * 100)}% similar</Tag>
)}
</div>
</div>
</div>
</List.Item>
);
};

const SimilarClimbs: React.FC<SimilarClimbsProps> = ({ params, boardDetails }) => {
const { data, error, isLoading } = useSWR(
[`similar-climbs`, params.climb_uuid],
() => fetchSimilarClimbs(params, 0.9, 10),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
);

if (isLoading) {
return (
<Card title="Similar Climbs" size="small">
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin />
</div>
</Card>
);
}

if (error) {
return (
<Card title="Similar Climbs" size="small">
<Text type="danger">Failed to load similar climbs</Text>
</Card>
);
}

const hasExactMatches = data?.exactLargerMatches && data.exactLargerMatches.length > 0;
const hasSimilarityMatches = data?.highSimilarityMatches && data.highSimilarityMatches.length > 0;

if (!hasExactMatches && !hasSimilarityMatches) {
return (
<Card title="Similar Climbs" size="small">
<Empty description="No similar climbs found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
);
}

return (
<Card title="Similar Climbs" size="small">
{hasExactMatches && (
<>
<Title level={5} style={{ marginTop: 0, marginBottom: '8px' }}>
Same climb on larger boards
</Title>
<List
size="small"
dataSource={data.exactLargerMatches}
renderItem={(climb) => (
<SimilarClimbItem climb={climb} boardDetails={boardDetails} params={params} />
)}
/>
</>
)}

{hasSimilarityMatches && (
<>
<Title level={5} style={{ marginTop: hasExactMatches ? '16px' : 0, marginBottom: '8px' }}>
Similar climbs (90%+ matching holds)
</Title>
<List
size="small"
dataSource={data.highSimilarityMatches}
renderItem={(climb) => (
<SimilarClimbItem climb={climb} boardDetails={boardDetails} params={params} />
)}
/>
</>
)}
</Card>
);
};

export default SimilarClimbs;
Loading