diff --git a/packages/db/drizzle/0041_add_holds_hash.sql b/packages/db/drizzle/0041_add_holds_hash.sql new file mode 100644 index 00000000..7c49d99c --- /dev/null +++ b/packages/db/drizzle/0041_add_holds_hash.sql @@ -0,0 +1,7 @@ +-- Add holds_hash column to board_climbs table for duplicate detection +ALTER TABLE "board_climbs" ADD COLUMN "holds_hash" text;--> statement-breakpoint +-- Create index for efficient duplicate lookups by board type, layout, and holds hash +CREATE INDEX IF NOT EXISTS "board_climbs_holds_hash_idx" ON "board_climbs" ("board_type","layout_id","holds_hash");--> statement-breakpoint +-- Create unique partial index to prevent duplicate climbs at the database level +-- Only enforced for non-empty holds_hash values (climbs without valid frames are excluded) +CREATE UNIQUE INDEX IF NOT EXISTS "board_climbs_holds_hash_unique_idx" ON "board_climbs" ("board_type","layout_id","holds_hash") WHERE "holds_hash" IS NOT NULL AND "holds_hash" != ''; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 38065c30..1493b4db 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1768444800000, "tag": "0040_add_climb_user_id", "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1768531200000, + "tag": "0041_add_holds_hash", + "breakpoints": true } ] } diff --git a/packages/db/scripts/backfill-holds-hash.ts b/packages/db/scripts/backfill-holds-hash.ts new file mode 100644 index 00000000..fc454b69 --- /dev/null +++ b/packages/db/scripts/backfill-holds-hash.ts @@ -0,0 +1,78 @@ +/** + * Backfill script to compute and populate holds_hash for existing climbs. + * + * Run this script after applying the migration that adds the holds_hash column. + * It will process climbs in batches to avoid memory issues with large datasets. + * + * Usage: + * cd packages/db + * npx tsx scripts/backfill-holds-hash.ts + */ + +import { drizzle } from 'drizzle-orm/neon-serverless'; +import { neon } from '@neondatabase/serverless'; +import { eq, isNull } from 'drizzle-orm'; +import { boardClimbs } from '../src/schema/boards/unified'; +import { generateHoldsHash } from '../src/utils/holds-hash'; + +async function backfillHoldsHash() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error('DATABASE_URL environment variable is required'); + process.exit(1); + } + + const sql = neon(databaseUrl); + const db = drizzle(sql); + + const BATCH_SIZE = 1000; + let totalProcessed = 0; + let totalUpdated = 0; + + console.log('Starting holds_hash backfill...'); + + // Process climbs without a holds_hash + while (true) { + // Fetch a batch of climbs that need processing + const climbs = await db + .select({ + uuid: boardClimbs.uuid, + frames: boardClimbs.frames, + boardType: boardClimbs.boardType, + layoutId: boardClimbs.layoutId, + }) + .from(boardClimbs) + .where(isNull(boardClimbs.holdsHash)) + .limit(BATCH_SIZE); + + if (climbs.length === 0) { + console.log('No more climbs to process.'); + break; + } + + console.log(`Processing batch of ${climbs.length} climbs...`); + + // Update each climb with its computed hash + for (const climb of climbs) { + if (climb.frames) { + const hash = generateHoldsHash(climb.frames); + if (hash) { + await db + .update(boardClimbs) + .set({ holdsHash: hash }) + .where(eq(boardClimbs.uuid, climb.uuid)); + totalUpdated++; + } + } + totalProcessed++; + } + + console.log(`Processed ${totalProcessed} climbs, updated ${totalUpdated} with hash...`); + } + + console.log(`\nBackfill complete!`); + console.log(`Total climbs processed: ${totalProcessed}`); + console.log(`Total climbs updated with hash: ${totalUpdated}`); +} + +backfillHoldsHash().catch(console.error); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 2d9d4712..763ffe1a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -4,3 +4,6 @@ export * from './relations/index'; // Re-export client export * from './client/index'; + +// Re-export utilities +export * from './utils/holds-hash'; diff --git a/packages/db/src/schema/boards/unified.ts b/packages/db/src/schema/boards/unified.ts index 15dd033e..7c34d187 100644 --- a/packages/db/src/schema/boards/unified.ts +++ b/packages/db/src/schema/boards/unified.ts @@ -244,6 +244,8 @@ export const boardClimbs = pgTable('board_climbs', { syncError: text('sync_error'), // Boardsesh user who created this climb locally (null for Aurora-synced climbs) userId: text('user_id').references(() => users.id, { onDelete: 'set null' }), + // Hash of holds for duplicate detection (sorted holdId:state pairs) + holdsHash: text('holds_hash'), }, (table) => ({ boardTypeIdx: index('board_climbs_board_type_idx').on(table.boardType), layoutFilterIdx: index('board_climbs_layout_filter_idx').on( @@ -260,6 +262,12 @@ export const boardClimbs = pgTable('board_climbs', { table.edgeBottom, table.edgeTop, ), + // Index for efficient duplicate detection + holdsHashIdx: index('board_climbs_holds_hash_idx').on( + table.boardType, + table.layoutId, + table.holdsHash, + ), // Note: No FK to board_layouts - climbs may reference layouts that don't exist during sync })); diff --git a/packages/db/src/utils/holds-hash.ts b/packages/db/src/utils/holds-hash.ts new file mode 100644 index 00000000..7bebf6cf --- /dev/null +++ b/packages/db/src/utils/holds-hash.ts @@ -0,0 +1,81 @@ +/** + * Utility for generating a deterministic hash from climb frames. + * Used for duplicate detection - same holds with same states produce the same hash. + * + * This is in the db package so it can be shared between: + * - packages/web (saveClimb, shared-sync) + * - packages/db/scripts (backfill script) + */ + +interface HoldStatePair { + holdId: number; + roleCode: number; +} + +/** + * Parse a frames string to extract hold-state pairs. + * Frames format: "p{holdId}r{roleCode}p{holdId}r{roleCode}..." + * Multiple frames are separated by commas. + * + * For duplicate detection, we flatten all frames since we care about + * the complete set of holds, not their frame organization. + */ +function parseFramesToHoldStatePairs(frames: string): HoldStatePair[] { + const pairs: HoldStatePair[] = []; + + // Split by frames (comma-separated), then process each frame + const frameStrings = frames.split(',').filter(Boolean); + + for (const frameString of frameStrings) { + // Parse format: p{holdId}r{roleCode}p{holdId}r{roleCode}... + const holdMatches = frameString.matchAll(/p(\d+)r(\d+)/g); + + for (const match of holdMatches) { + pairs.push({ + holdId: parseInt(match[1], 10), + roleCode: parseInt(match[2], 10), + }); + } + } + + return pairs; +} + +/** + * Generate a deterministic hash string from a frames string. + * + * The hash is a canonical string representation of sorted hold-state pairs: + * "holdId:roleCode|holdId:roleCode|..." + * + * This ensures the same holds with the same states always produce + * the same hash, regardless of the order they appear in the frames. + */ +export function generateHoldsHash(frames: string): string { + if (!frames || frames.trim() === '') { + return ''; + } + + const pairs = parseFramesToHoldStatePairs(frames); + + if (pairs.length === 0) { + return ''; + } + + // Sort pairs by holdId first, then by roleCode for determinism + pairs.sort((a, b) => { + if (a.holdId !== b.holdId) { + return a.holdId - b.holdId; + } + return a.roleCode - b.roleCode; + }); + + // Create canonical string: "holdId:roleCode|holdId:roleCode|..." + return pairs.map(p => `${p.holdId}:${p.roleCode}`).join('|'); +} + +/** + * Check if two frames strings represent the same set of holds. + */ +export function framesAreEquivalent(frames1: string, frames2: string): boolean { + return generateHoldsHash(frames1) === generateHoldsHash(frames2); +} diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/create/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/create/page.tsx index 2e93c057..a282fc77 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/create/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/create/page.tsx @@ -64,7 +64,7 @@ export default async function CreateClimbPage(props: CreateClimbPageProps) { return ( diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/import/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/import/page.tsx index ce909b71..1adb1e6e 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/import/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/import/page.tsx @@ -67,6 +67,7 @@ export default async function ImportPage(props: ImportPageProps) { diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout.tsx index 1a9ed9f4..5fb572fa 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/layout.tsx @@ -2,13 +2,25 @@ import React from 'react'; import { PropsWithChildren } from 'react'; -import { BoardRouteParameters, ParsedBoardRouteParameters } from '@/app/lib/types'; +import { BoardRouteParameters, ParsedBoardRouteParameters, BoardDetails } from '@/app/lib/types'; import { parseBoardRouteParams, constructClimbListWithSlugs } from '@/app/lib/url-utils'; import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; +import { getMoonBoardDetails } from '@/app/lib/moonboard-config'; import { permanentRedirect } from 'next/navigation'; import ListLayoutClient from './layout-client'; +// Helper to get board details for any board type +function getBoardDetailsForBoard(params: ParsedBoardRouteParameters): BoardDetails { + if (params.board_name === 'moonboard') { + return getMoonBoardDetails({ + layout_id: params.layout_id, + set_ids: params.set_ids, + }); + } + return getBoardDetails(params); +} + interface LayoutProps { params: Promise; } @@ -30,7 +42,7 @@ export default async function ListLayout(props: PropsWithChildren) parsedParams = parseBoardRouteParams(params); // Redirect old URLs to new slug format - const boardDetails = await getBoardDetails(parsedParams); + const boardDetails = getBoardDetailsForBoard(parsedParams); if (boardDetails.layout_name && boardDetails.size_name && boardDetails.set_names) { const newUrl = constructClimbListWithSlugs( @@ -50,7 +62,7 @@ export default async function ListLayout(props: PropsWithChildren) } // Fetch the climbs and board details server-side - const boardDetails = await getBoardDetails(parsedParams); + const boardDetails = getBoardDetailsForBoard(parsedParams); return {children}; } diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx index ace45492..4bbd1821 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/list/page.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { notFound, permanentRedirect } from 'next/navigation'; -import { BoardRouteParametersWithUuid, SearchRequestPagination, BoardDetails } from '@/app/lib/types'; +import { BoardRouteParametersWithUuid, SearchRequestPagination, BoardDetails, BoardName, Climb } from '@/app/lib/types'; +import { SetIdList } from '@/app/lib/board-data'; import { parseBoardRouteParams, parsedRouteSearchParamsToSearchParams, @@ -12,7 +13,93 @@ import ClimbsList from '@/app/components/board-page/climbs-list'; import { cachedSearchClimbs } from '@/app/lib/graphql/server-cached-client'; import { SEARCH_CLIMBS, type ClimbSearchResponse } from '@/app/lib/graphql/operations/climb-search'; import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; +import { getMoonBoardDetails, MOONBOARD_HOLD_STATE_CODES } from '@/app/lib/moonboard-config'; import { MAX_PAGE_SIZE } from '@/app/components/board-page/constants'; +import { dbz } from '@/app/lib/db/db'; +import { UNIFIED_TABLES } from '@/app/lib/db/queries/util/table-select'; +import { eq, and, desc } from 'drizzle-orm'; +import type { LitUpHoldsMap, HoldState } from '@/app/components/board-renderer/types'; + +// Helper to get board details for any board type +function getBoardDetailsForBoard(params: { board_name: BoardName; layout_id: number; size_id: number; set_ids: SetIdList }): BoardDetails { + if (params.board_name === 'moonboard') { + return getMoonBoardDetails({ + layout_id: params.layout_id, + set_ids: params.set_ids, + }); + } + return getBoardDetails(params); +} + +// Parse Moonboard frames string to lit up holds map +function parseMoonboardFrames(frames: string): LitUpHoldsMap { + const map: LitUpHoldsMap = {}; + // Format: p{holdId}r{roleCode} e.g., "p1r42p45r43p198r44" + const regex = /p(\d+)r(\d+)/g; + let match; + while ((match = regex.exec(frames)) !== null) { + const holdId = parseInt(match[1], 10); + const roleCode = parseInt(match[2], 10); + let state: HoldState = 'HAND'; + let color = '#0000FF'; + let displayColor = '#4444FF'; + + if (roleCode === MOONBOARD_HOLD_STATE_CODES.start) { + state = 'STARTING'; + color = '#FF0000'; + displayColor = '#FF3333'; + } else if (roleCode === MOONBOARD_HOLD_STATE_CODES.finish) { + state = 'FINISH'; + color = '#00FF00'; + displayColor = '#44FF44'; + } + + map[holdId] = { state, color, displayColor }; + } + return map; +} + +// Query Moonboard climbs directly from the database +async function getMoonboardClimbs(layoutId: number, angle: number, limit: number): Promise { + const { climbs } = UNIFIED_TABLES; + + const results = await dbz + .select({ + uuid: climbs.uuid, + name: climbs.name, + description: climbs.description, + frames: climbs.frames, + angle: climbs.angle, + setterUsername: climbs.setterUsername, + createdAt: climbs.createdAt, + }) + .from(climbs) + .where( + and( + eq(climbs.boardType, 'moonboard'), + eq(climbs.layoutId, layoutId), + eq(climbs.angle, angle) + ) + ) + .orderBy(desc(climbs.createdAt)) + .limit(limit); + + return results.map((row) => ({ + uuid: row.uuid, + setter_username: row.setterUsername || 'Unknown', + name: row.name || 'Unnamed Climb', + description: row.description || '', + frames: row.frames || '', + angle: row.angle || angle, + ascensionist_count: 0, + difficulty: 'V?', + quality_average: '0', + stars: 0, + difficulty_error: '0', + litUpHoldsMap: parseMoonboardFrames(row.frames || ''), + benchmark_difficulty: null, + })); +} export default async function DynamicResultsPage(props: { params: Promise; @@ -32,7 +119,7 @@ export default async function DynamicResultsPage(props: { parsedParams = parseBoardRouteParams(params); // Redirect old URLs to new slug format - const boardDetails = await getBoardDetails(parsedParams); + const boardDetails = getBoardDetailsForBoard(parsedParams); if (boardDetails.layout_name && boardDetails.size_name && boardDetails.set_names) { const newUrl = constructClimbListWithSlugs( @@ -113,10 +200,29 @@ export default async function DynamicResultsPage(props: { let boardDetails: BoardDetails; try { - [searchResponse, boardDetails] = await Promise.all([ - cachedSearchClimbs(SEARCH_CLIMBS, { input: searchInput }, isDefaultSearch), - getBoardDetails(parsedParams), - ]); + boardDetails = getBoardDetailsForBoard(parsedParams); + + // Moonboard queries the database directly (no GraphQL support yet) + if (parsedParams.board_name === 'moonboard') { + const moonboardClimbs = await getMoonboardClimbs( + parsedParams.layout_id, + parsedParams.angle, + searchParamsObject.pageSize || 50 + ); + searchResponse = { + searchClimbs: { + climbs: moonboardClimbs, + totalCount: moonboardClimbs.length, + hasMore: false, + }, + }; + } else { + searchResponse = await cachedSearchClimbs( + SEARCH_CLIMBS, + { input: searchInput }, + isDefaultSearch, + ); + } } catch (error) { console.error('Error fetching results or climb:', error); notFound(); diff --git a/packages/web/app/api/v1/[board_name]/proxy/saveClimb/route.ts b/packages/web/app/api/v1/[board_name]/proxy/saveClimb/route.ts index 896616f7..034ec83a 100644 --- a/packages/web/app/api/v1/[board_name]/proxy/saveClimb/route.ts +++ b/packages/web/app/api/v1/[board_name]/proxy/saveClimb/route.ts @@ -1,6 +1,8 @@ // app/api/v1/[board_name]/proxy/saveClimb/route.ts import { saveClimb } from '@/app/lib/api-wrappers/aurora/saveClimb'; import { AuroraBoardName } from '@/app/lib/api-wrappers/aurora/types'; +import { BoardName } from '@/app/lib/types'; +import { encodeMoonBoardHoldsToFrames } from '@/app/lib/moonboard-config'; import { NextResponse } from 'next/server'; import { z } from 'zod'; @@ -20,22 +22,56 @@ const saveClimbSchema = z.object({ .strict(), }); +// Moonboard-specific schema (uses holds instead of frames) +const saveMoonBoardClimbSchema = z.object({ + options: z + .object({ + layout_id: z.number(), + user_id: z.string().min(1), // NextAuth user ID (UUID) + name: z.string().min(1), + description: z.string(), + holds: z.object({ + start: z.array(z.string()), + hand: z.array(z.string()), + finish: z.array(z.string()), + }), + angle: z.number(), + }) + .strict(), +}); + export async function POST(request: Request, props: { params: Promise<{ board_name: string }> }) { const params = await props.params; - - // MoonBoard doesn't use Aurora APIs - if (params.board_name === 'moonboard') { - return NextResponse.json({ error: 'MoonBoard does not support this endpoint' }, { status: 400 }); - } - - const board_name = params.board_name as AuroraBoardName; + const board_name = params.board_name as BoardName; try { const body = await request.json(); + + // Handle Moonboard separately (uses holds instead of frames) + if (board_name === 'moonboard') { + const validatedData = saveMoonBoardClimbSchema.parse(body); + const frames = encodeMoonBoardHoldsToFrames(validatedData.options.holds); + + const response = await saveClimb('moonboard', { + layout_id: validatedData.options.layout_id, + user_id: validatedData.options.user_id, + name: validatedData.options.name, + description: validatedData.options.description, + angle: validatedData.options.angle, + frames, + is_draft: false, + frames_count: 1, + frames_pace: 0, + }); + return NextResponse.json(response); + } + + // Aurora boards (kilter, tension) const validatedData = saveClimbSchema.parse(body); + const aurora_board_name = board_name as AuroraBoardName; // saveClimb saves to local database only (no Aurora sync) - const response = await saveClimb(board_name, validatedData.options); + const response = await saveClimb(aurora_board_name, validatedData.options); return NextResponse.json(response); } catch (error) { console.error('SaveClimb error details:', { diff --git a/packages/web/app/components/create-climb/create-climb-form.module.css b/packages/web/app/components/create-climb/create-climb-form.module.css index 2a52b0a8..2ab6440f 100644 --- a/packages/web/app/components/create-climb/create-climb-form.module.css +++ b/packages/web/app/components/create-climb/create-climb-form.module.css @@ -10,20 +10,60 @@ background-color: var(--semantic-background, #F9FAFB); } +/* Header for create page with back and save buttons */ +.createHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + background-color: var(--semantic-surface, #FFFFFF); + border-bottom: 1px solid var(--border-subtle, #E5E7EB); + flex-shrink: 0; +} + +.headerNameInput { + flex: 1; + font-size: 16px; + font-weight: 500; + text-align: center; + min-width: 0; +} + +.headerNameInput input { + text-align: center; +} + +/* Alert banner styling */ +.alertBanner { + margin: 8px 12px; + flex-shrink: 0; +} + +/* Validation bar at bottom */ +.validationBar { + padding: 8px 12px; + text-align: center; + background-color: var(--semantic-surface, #FFFFFF); + border-top: 1px solid var(--border-subtle, #E5E7EB); + flex-shrink: 0; +} + /* Auth alert styling */ .authAlert { margin: 8px 12px; flex-shrink: 0; } -/* Content wrapper - matches play view */ +/* Content wrapper - scrollable for mobile */ .contentWrapper { flex: 1; display: flex; flex-direction: column; - overflow: hidden; + overflow: auto; position: relative; min-height: 0; + -webkit-overflow-scrolling: touch; } /* Title container - same position as play view */ @@ -115,13 +155,11 @@ .boardContainer { flex: 1; display: flex; - align-items: flex-start; + align-items: center; justify-content: center; - padding: 0; - min-height: 0; - max-height: 100%; + padding: 12px; + min-height: 300px; width: 100%; - overflow: hidden; position: relative; } diff --git a/packages/web/app/components/create-climb/moonboard-create-climb-form.tsx b/packages/web/app/components/create-climb/moonboard-create-climb-form.tsx index 347c5490..b7bcab30 100644 --- a/packages/web/app/components/create-climb/moonboard-create-climb-form.tsx +++ b/packages/web/app/components/create-climb/moonboard-create-climb-form.tsx @@ -1,15 +1,16 @@ 'use client'; import React, { useState } from 'react'; -import { Form, Input, Button, Typography, Tag, Alert, Upload, message } from 'antd'; -import { ExperimentOutlined, UploadOutlined, LoadingOutlined, ImportOutlined } from '@ant-design/icons'; +import { Form, Input, Button, Typography, Tag, Alert, Upload, message, Space } from 'antd'; +import { UploadOutlined, LoadingOutlined, ImportOutlined, LoginOutlined, ArrowLeftOutlined, SaveOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import { useRouter, usePathname } from 'next/navigation'; import Link from 'next/link'; +import { useSession } from 'next-auth/react'; import MoonBoardRenderer from '../moonboard-renderer/moonboard-renderer'; import { useMoonBoardCreateClimb } from './use-moonboard-create-climb'; import { holdIdToCoordinate } from '@/app/lib/moonboard-config'; import { parseScreenshot } from '@boardsesh/moonboard-ocr/browser'; -import { saveMoonBoardClimb, convertOcrHoldsToMap } from '@/app/lib/moonboard-climbs-db'; +import { convertOcrHoldsToMap } from '@/app/lib/moonboard-climbs-db'; import styles from './create-climb-form.module.css'; const { TextArea } = Input; @@ -22,19 +23,20 @@ interface MoonBoardCreateClimbFormValues { interface MoonBoardCreateClimbFormProps { layoutFolder: string; - layoutName: string; + layoutId: number; holdSetImages: string[]; angle: number; } export default function MoonBoardCreateClimbForm({ layoutFolder, - layoutName, + layoutId, holdSetImages, angle, }: MoonBoardCreateClimbFormProps) { const router = useRouter(); const pathname = usePathname(); + const { data: session } = useSession(); // Construct the bulk import URL (replace /create with /import) const bulkImportUrl = pathname.replace(/\/create$/, '/import'); @@ -53,7 +55,12 @@ export default function MoonBoardCreateClimbForm({ const [form] = Form.useForm(); const [isSaving, setIsSaving] = useState(false); - const [saveSuccess, setSaveSuccess] = useState(false); + + // Duplicate climb state + const [duplicateInfo, setDuplicateInfo] = useState<{ + uuid: string; + name?: string; + } | null>(null); // OCR import state const [isOcrProcessing, setIsOcrProcessing] = useState(false); @@ -110,7 +117,13 @@ export default function MoonBoardCreateClimbForm({ return; } + if (!session?.user?.id) { + message.error('Please log in to save climbs'); + return; + } + setIsSaving(true); + setDuplicateInfo(null); try { // Convert holds to coordinate format for storage @@ -126,180 +139,219 @@ export default function MoonBoardCreateClimbForm({ .map(([id]) => holdIdToCoordinate(Number(id))), }; - const climbData = { - name: values.name, - description: values.description || '', - holds, - angle, - layoutFolder, - createdAt: new Date().toISOString(), - }; + const response = await fetch('/api/v1/moonboard/proxy/saveClimb', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + options: { + layout_id: layoutId, + user_id: session.user.id, + name: values.name, + description: values.description || '', + holds, + angle, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to save climb'); + } - await saveMoonBoardClimb(climbData); + const data = await response.json(); - message.success('Climb saved successfully!'); - setSaveSuccess(true); + // Check if this is a duplicate climb + if (data.isDuplicate) { + setDuplicateInfo({ + uuid: data.existingClimbUuid, + name: data.existingClimbName, + }); + return; + } - // Reset the form and holds - form.resetFields(); - resetHolds(); + message.success('Climb saved to database!'); - // Show success briefly then allow another climb to be created - setTimeout(() => { - setSaveSuccess(false); - }, 3000); + // Navigate back to the list + const listUrl = pathname.replace(/\/create$/, '/list'); + router.push(listUrl); } catch (error) { console.error('Failed to save climb:', error); - message.error('Failed to save climb. Please try again.'); + message.error(error instanceof Error ? error.message : 'Failed to save climb. Please try again.'); } finally { setIsSaving(false); } }; + const handleViewDuplicate = () => { + if (duplicateInfo) { + // Navigate to the existing climb + const climbUrl = pathname.replace(/\/create$/, `/climb/${duplicateInfo.uuid}`); + router.push(climbUrl); + } + }; + + const handleDismissDuplicate = () => { + setDuplicateInfo(null); + }; + const handleCancel = () => { router.back(); }; return (
- } - className={styles.betaBanner} - banner - /> - - {saveSuccess && ( - - )} + {/* Header with Back and Save buttons */} +
+ +
+ + + +
+ {!session?.user ? ( + + + + ) : ( + + )} +
{ocrError && ( setOcrError(null)} - className={styles.betaBanner} + className={styles.alertBanner} /> )} {ocrWarnings.length > 0 && (
{w}
)} type="warning" showIcon closable onClose={() => setOcrWarnings([])} - className={styles.betaBanner} + className={styles.alertBanner} + /> + )} + + {duplicateInfo && ( + + + A climb with the same holds already exists + {duplicateInfo.name ? `: "${duplicateInfo.name}"` : ''} + + + + + + + } + type="warning" + showIcon + icon={} + className={styles.alertBanner} /> )}
{/* Board Section */} -
+
- - {/* Import from Screenshot */} -
- { - handleOcrImport(file); - return false; // Prevent default upload behavior - }} - disabled={isOcrProcessing} - > - - - - - -
- - {/* Hold counts */} -
- 0 ? 'red' : 'default'}>Start: {startingCount}/2 - 0 ? 'blue' : 'default'}>Hand: {handCount} - 0 ? 'green' : 'default'}>Finish: {finishCount}/2 - 0 ? 'purple' : 'default'}>Total: {totalHolds} - {totalHolds > 0 && ( - - )} -
- - {!isValid && totalHolds > 0 && ( - - A valid climb needs at least 1 start hold and 1 finish hold - - )}
+
- {/* Form Section */} -
-
+ + 0 ? 'red' : 'default'}>Start: {startingCount}/2 + 0 ? 'blue' : 'default'}>Hand: {handCount} + 0 ? 'green' : 'default'}>Finish: {finishCount}/2 + 0 ? 'purple' : 'default'}>Total: {totalHolds} + + + {totalHolds > 0 && ( + + )} + { + handleOcrImport(file); + return false; }} - className={styles.formContent} + disabled={isOcrProcessing} > - - - - - -