diff --git a/packages/backend/src/__tests__/graphql-resolvers.test.ts b/packages/backend/src/__tests__/graphql-resolvers.test.ts index 8607d6b6..56debabc 100644 --- a/packages/backend/src/__tests__/graphql-resolvers.test.ts +++ b/packages/backend/src/__tests__/graphql-resolvers.test.ts @@ -233,7 +233,7 @@ describe('GraphQL Resolver Input Validation', () => { climbUuid: 'test-uuid', }, }, - 'Board name must be kilter or tension', + 'Board name must be kilter, tension, or moonboard', ); }); }); @@ -330,7 +330,7 @@ describe('GraphQL Resolver Input Validation', () => { }, }, }, - 'Board name must be kilter, tension', + 'Board name must be kilter, tension, or moonboard', ); }); }); diff --git a/packages/backend/src/db/queries/climbs/create-climb-filters.ts b/packages/backend/src/db/queries/climbs/create-climb-filters.ts index 141613c6..efd890c0 100644 --- a/packages/backend/src/db/queries/climbs/create-climb-filters.ts +++ b/packages/backend/src/db/queries/climbs/create-climb-filters.ts @@ -62,6 +62,7 @@ export const createClimbFilters = ( // Base conditions for filtering climbs that don't reference the product sizes table const baseConditions: SQL[] = [ + eq(tables.climbs.boardType, params.board_name), eq(tables.climbs.layoutId, params.layout_id), eq(tables.climbs.isListed, true), eq(tables.climbs.isDraft, false), @@ -244,6 +245,7 @@ export const createClimbFilters = ( // For use in the subquery with left join getClimbStatsJoinConditions: () => [ eq(tables.climbStats.climbUuid, tables.climbs.uuid), + eq(tables.climbStats.boardType, params.board_name), eq(tables.climbStats.angle, params.angle), ], diff --git a/packages/backend/src/db/queries/climbs/get-climb.ts b/packages/backend/src/db/queries/climbs/get-climb.ts index e6b5972e..88223016 100644 --- a/packages/backend/src/db/queries/climbs/get-climb.ts +++ b/packages/backend/src/db/queries/climbs/get-climb.ts @@ -31,6 +31,11 @@ const HOLD_STATE_MAP: Record< 7: { name: 'FINISH', displayColor: '#FF0000', color: '#FF0000' }, 8: { name: 'FOOT', displayColor: '#FF00FF', color: '#FF00FF' }, }, + moonboard: { + 42: { name: 'STARTING', color: '#00FF00', displayColor: '#44FF44' }, + 43: { name: 'HAND', color: '#0000FF', displayColor: '#4444FF' }, + 44: { name: 'FINISH', color: '#FF0000', displayColor: '#FF3333' }, + }, }; // Warned hold states to avoid log spam @@ -104,14 +109,18 @@ export const getClimbByUuid = async (params: GetClimbParams): Promise> = { ascents: sql`${tables.climbStats.ascensionistCount}`, difficulty: sql`ROUND(${tables.climbStats.displayDifficulty}::numeric, 0)`, @@ -101,7 +105,7 @@ export const searchClimbs = async ( // Popular: sum of ascents across ALL angles for this climb (using unified table) popular: sql`( SELECT COALESCE(SUM(cs.ascensionist_count), 0) - FROM ${sql.identifier(climbStatsTable)} cs + FROM ${boardClimbStats} cs WHERE cs.board_type = ${params.board_name} AND cs.climb_uuid = ${tables.climbs.uuid} )`, }; @@ -148,7 +152,10 @@ export const searchClimbs = async ( .leftJoin(tables.climbStats, and(...filters.getClimbStatsJoinConditions())) .leftJoin( tables.difficultyGrades, - eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`), + and( + eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`), + eq(tables.difficultyGrades.boardType, params.board_name), + ), ) .where(and(...whereConditions)) .orderBy( diff --git a/packages/backend/src/db/queries/util/product-sizes-data.ts b/packages/backend/src/db/queries/util/product-sizes-data.ts index 2fd4ab6f..de109b72 100644 --- a/packages/backend/src/db/queries/util/product-sizes-data.ts +++ b/packages/backend/src/db/queries/util/product-sizes-data.ts @@ -56,6 +56,10 @@ export const PRODUCT_SIZES: Record> = 8: { id: 8, name: '12 high x 8 wide', description: '', edgeLeft: -44, edgeRight: 44, edgeBottom: 0, edgeTop: 144, productId: 5 }, 9: { id: 9, name: '10 high x 8 wide', description: '', edgeLeft: -44, edgeRight: 44, edgeBottom: 0, edgeTop: 120, productId: 5 }, }, + // MoonBoard has a single fixed size: 11 columns (A-K) x 18 rows + moonboard: { + 1: { id: 1, name: 'Standard', description: '11x18 Grid', edgeLeft: 0, edgeRight: 11, edgeBottom: 0, edgeTop: 18, productId: 1 }, + }, }; /** diff --git a/packages/backend/src/db/queries/util/table-select.ts b/packages/backend/src/db/queries/util/table-select.ts index 5686f37a..ac7707e2 100644 --- a/packages/backend/src/db/queries/util/table-select.ts +++ b/packages/backend/src/db/queries/util/table-select.ts @@ -16,8 +16,10 @@ import { boardWalls, boardTags, } from '@boardsesh/db'; +import type { BoardName } from '@boardsesh/shared-schema'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; -export type BoardName = 'kilter' | 'tension'; +export type { BoardName }; // Unified tables - all queries should filter by board_type export const UNIFIED_TABLES = { @@ -58,21 +60,23 @@ export function getUnifiedTable( * @returns True if the board name is valid */ export function isValidBoardName(boardName: string): boardName is BoardName { - return boardName === 'kilter' || boardName === 'tension'; + return SUPPORTED_BOARDS.includes(boardName as BoardName); } /** * Extended board name type that includes moonboard for unified tables + * @deprecated BoardName now includes moonboard, use BoardName instead */ -export type UnifiedBoardName = BoardName | 'moonboard'; +export type UnifiedBoardName = BoardName; /** * Check if a board name is valid for unified tables (includes moonboard) * @param boardName The name to check * @returns True if the board name is valid for unified tables + * @deprecated Use isValidBoardName instead - it now includes moonboard */ export function isValidUnifiedBoardName(boardName: string): boardName is UnifiedBoardName { - return boardName === 'kilter' || boardName === 'tension' || boardName === 'moonboard'; + return isValidBoardName(boardName); } // ============================================================================= diff --git a/packages/backend/src/graphql/resolvers/climbs/queries.ts b/packages/backend/src/graphql/resolvers/climbs/queries.ts index 211acbd4..0d5dff5b 100644 --- a/packages/backend/src/graphql/resolvers/climbs/queries.ts +++ b/packages/backend/src/graphql/resolvers/climbs/queries.ts @@ -1,4 +1,5 @@ -import type { ClimbSearchInput, ConnectionContext } from '@boardsesh/shared-schema'; +import type { ClimbSearchInput, ConnectionContext, BoardName } from '@boardsesh/shared-schema'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; import type { ClimbSearchParams, ParsedBoardRouteParameters } from '../../../db/queries/climbs/index'; import { getClimbByUuid } from '../../../db/queries/climbs/index'; import { getSizeEdges } from '../../../db/queries/util/product-sizes-data'; @@ -20,7 +21,7 @@ export const climbQueries = { // Validate board name if (!isValidBoardName(input.boardName)) { - throw new Error(`Invalid board name: ${input.boardName}. Must be 'kilter' or 'tension'`); + throw new Error(`Invalid board name: ${input.boardName}. Must be one of: ${SUPPORTED_BOARDS.join(', ')}`); } // Get size edges for filtering @@ -34,7 +35,7 @@ export const climbQueries = { // Build route parameters const params: ParsedBoardRouteParameters = { - board_name: input.boardName as 'kilter' | 'tension', + board_name: input.boardName as BoardName, layout_id: input.layoutId, size_id: input.sizeId, set_ids: setIds, @@ -89,7 +90,7 @@ export const climbQueries = { validateInput(BoardNameSchema, boardName, 'boardName'); if (!isValidBoardName(boardName)) { - throw new Error(`Invalid board name: ${boardName}. Must be 'kilter' or 'tension'`); + throw new Error(`Invalid board name: ${boardName}. Must be one of: ${SUPPORTED_BOARDS.join(', ')}`); } // Validate all parameters @@ -101,7 +102,7 @@ export const climbQueries = { if (DEBUG) console.log('[climb] Fetching:', { boardName, layoutId, sizeId, setIds, angle, climbUuid }); const climb = await getClimbByUuid({ - board_name: boardName as 'kilter' | 'tension', + board_name: boardName as BoardName, layout_id: layoutId, size_id: sizeId, angle, diff --git a/packages/backend/src/graphql/resolvers/playlists/queries.ts b/packages/backend/src/graphql/resolvers/playlists/queries.ts index 4848858b..e6837a2c 100644 --- a/packages/backend/src/graphql/resolvers/playlists/queries.ts +++ b/packages/backend/src/graphql/resolvers/playlists/queries.ts @@ -1,5 +1,6 @@ import { eq, and, inArray, desc, sql, or, isNull, asc } from 'drizzle-orm'; -import type { ConnectionContext, Climb } from '@boardsesh/shared-schema'; +import type { ConnectionContext, Climb, BoardName } from '@boardsesh/shared-schema'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; import { db } from '../../../db/client'; import * as dbSchema from '@boardsesh/db/schema'; import { requireAuthenticated, validateInput } from '../shared/helpers'; @@ -17,7 +18,7 @@ import type { LitUpHoldsMap, HoldState } from '@boardsesh/shared-schema'; // Hold state mapping for converting frames string to lit up holds map type HoldColor = string; type HoldCode = number; -type BoardName = 'kilter' | 'tension'; +// BoardName is imported from @boardsesh/shared-schema const HOLD_STATE_MAP: Record< BoardName, @@ -43,6 +44,12 @@ const HOLD_STATE_MAP: Record< 7: { name: 'FINISH', displayColor: '#FF0000', color: '#FF0000' }, 8: { name: 'FOOT', displayColor: '#FF00FF', color: '#FF00FF' }, }, + // MoonBoard uses a different hold format - holds are identified by grid position + moonboard: { + 1: { name: 'STARTING', color: '#00FF00' }, + 2: { name: 'HAND', color: '#0000FF' }, + 3: { name: 'FINISH', color: '#FF0000' }, + }, }; function convertLitUpHoldsStringToMap(litUpHolds: string, board: BoardName): Record { @@ -290,7 +297,7 @@ export const playlistQueries = { // Validate board name if (!isValidBoardName(boardName)) { - throw new Error(`Invalid board name: ${boardName}. Must be 'kilter' or 'tension'`); + throw new Error(`Invalid board name: ${boardName}. Must be one of: ${SUPPORTED_BOARDS.join(', ')}`); } // Get size edges for filtering @@ -361,19 +368,26 @@ export const playlistQueries = { .from(dbSchema.playlistClimbs) .innerJoin( tables.climbs, - eq(tables.climbs.uuid, dbSchema.playlistClimbs.climbUuid) + and( + eq(tables.climbs.uuid, dbSchema.playlistClimbs.climbUuid), + eq(tables.climbs.boardType, boardName) + ) ) .leftJoin( tables.climbStats, and( eq(tables.climbStats.climbUuid, dbSchema.playlistClimbs.climbUuid), + eq(tables.climbStats.boardType, boardName), // Use the route angle (from input) to fetch stats for the current board angle eq(tables.climbStats.angle, input.angle) ) ) .leftJoin( tables.difficultyGrades, - eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`) + and( + eq(tables.difficultyGrades.difficulty, sql`ROUND(${tables.climbStats.displayDifficulty}::numeric)`), + eq(tables.difficultyGrades.boardType, boardName) + ) ) .where(eq(dbSchema.playlistClimbs.playlistId, playlistId)) .orderBy(asc(dbSchema.playlistClimbs.position), asc(dbSchema.playlistClimbs.addedAt)) diff --git a/packages/backend/src/graphql/resolvers/ticks/queries.ts b/packages/backend/src/graphql/resolvers/ticks/queries.ts index f8a3f753..15d4b571 100644 --- a/packages/backend/src/graphql/resolvers/ticks/queries.ts +++ b/packages/backend/src/graphql/resolvers/ticks/queries.ts @@ -1,5 +1,6 @@ import { eq, and, desc, inArray, sql, count } from 'drizzle-orm'; -import type { ConnectionContext } from '@boardsesh/shared-schema'; +import type { ConnectionContext, BoardName } from '@boardsesh/shared-schema'; +import { SUPPORTED_BOARDS } from '@boardsesh/shared-schema'; import { db } from '../../../db/client'; import * as dbSchema from '@boardsesh/db/schema'; import { requireAuthenticated, validateInput } from '../shared/helpers'; @@ -315,13 +316,13 @@ export const tickQueries = { boardType: tick.boardType, layoutId, angle: tick.angle, - isMirror: tick.isMirror, + isMirror: tick.isMirror ?? false, status: tick.status, attemptCount: tick.attemptCount, quality: tick.quality, difficulty: tick.difficulty, difficultyName, - isBenchmark: tick.isBenchmark, + isBenchmark: tick.isBenchmark ?? false, comment: tick.comment || '', climbedAt: tick.climbedAt, frames, @@ -336,10 +337,10 @@ export const tickQueries = { boardType: tick.boardType, layoutId, angle: tick.angle, - isMirror: tick.isMirror, + isMirror: tick.isMirror ?? false, frames, difficultyName, - isBenchmark: tick.isBenchmark, + isBenchmark: tick.isBenchmark ?? false, date, items: [], flashCount: 0, @@ -423,7 +424,7 @@ export const tickQueries = { return { totalDistinctClimbs: 0, layoutStats: [] }; } - const boardTypes = ['kilter', 'tension'] as const; + const boardTypes = SUPPORTED_BOARDS; const layoutStatsMap: Record(); // Helper function to fetch stats for a single board type - const fetchBoardStats = async (boardType: 'kilter' | 'tension') => { + const fetchBoardStats = async (boardType: BoardName) => { // Run both queries in parallel for this board type const [gradeResults, distinctClimbs] = await Promise.all([ // Get distinct climb counts grouped by layoutId and difficulty using SQL aggregation diff --git a/packages/backend/src/handlers/ocr-test-data.ts b/packages/backend/src/handlers/ocr-test-data.ts new file mode 100644 index 00000000..c352812d --- /dev/null +++ b/packages/backend/src/handlers/ocr-test-data.ts @@ -0,0 +1,258 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import Busboy from 'busboy'; +import { v4 as uuidv4 } from 'uuid'; +import { applyCorsHeaders } from './cors'; +import { validateNextAuthToken } from '../middleware/auth'; +import { isS3Configured, uploadToS3 } from '../storage/s3'; + +// OCR test data upload configuration +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']; +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +}; + +// Expected metadata structure from OCR upload +// This matches the metadata object created in moonboard-ocr-upload.ts +interface OcrUploadMetadata { + layoutId: number; + angle: number; + climb: { + name?: string; + grade?: string; + holds?: unknown; + sourceFile?: string; + }; +} + +/** + * Type guard to check if the parsed metadata has the expected structure. + * We only validate the presence of required fields, not deep structure, + * since this is for test data collection and flexibility is preferred. + */ +function isValidOcrMetadata(value: unknown): value is OcrUploadMetadata { + if (typeof value !== 'object' || value === null) { + return false; + } + const obj = value as Record; + return ( + typeof obj.layoutId === 'number' && + typeof obj.angle === 'number' && + typeof obj.climb === 'object' && + obj.climb !== null + ); +} + +/** + * Extract auth token from Authorization header + */ +function extractAuthTokenFromHeader(req: IncomingMessage): string | null { + const authHeader = req.headers.authorization; + if (!authHeader) return null; + + const match = authHeader.match(/^Bearer\s+(.+)$/i); + return match ? match[1] : null; +} + +/** + * Generate a unique folder name for OCR test data + * Format: {ISO-timestamp}-{uuid} + */ +function generateFolderName(): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const uuid = uuidv4(); + return `${timestamp}-${uuid}`; +} + +/** + * OCR test data upload handler + * POST /api/ocr-test-data + * + * Expects multipart form data with: + * - image: the screenshot file + * - metadata: JSON string with OCR results and metadata + * + * Requires authentication via Authorization header (Bearer token). + * Any logged-in user can contribute test data. + * + * This is a fire-and-forget upload - errors are logged but don't fail the request. + */ +export async function handleOcrTestDataUpload(req: IncomingMessage, res: ServerResponse): Promise { + if (!applyCorsHeaders(req, res)) return; + + // Check if S3 is configured - if not, skip silently + if (!isS3Configured()) { + console.log('[OCR Test Data] S3 not configured, skipping upload'); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, skipped: true, reason: 'S3 not configured' })); + return; + } + + // Validate authentication + const token = extractAuthTokenFromHeader(req); + if (!token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Authentication required' })); + return; + } + + const authResult = await validateNextAuthToken(token); + if (!authResult) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid or expired token' })); + return; + } + + return new Promise((resolve) => { + let busboy: ReturnType; + + try { + busboy = Busboy({ + headers: req.headers as { 'content-type': string }, + limits: { fileSize: MAX_FILE_SIZE, files: 1 }, + }); + } catch (err) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request format' })); + resolve(); + return; + } + + let metadataJson: string | undefined; + let fileBuffer: Buffer | undefined; + let mimeType: string | undefined; + let originalFilename: string | undefined; + let fileTruncated = false; + let invalidMimeType = false; + + busboy.on('field', (name: string, value: string) => { + if (name === 'metadata') metadataJson = value; + }); + + busboy.on('file', (name: string, stream: NodeJS.ReadableStream, info: { filename: string; mimeType: string }) => { + if (name !== 'image') { + stream.resume(); + return; + } + + mimeType = info.mimeType; + originalFilename = info.filename; + + if (!ALLOWED_MIME_TYPES.includes(mimeType)) { + invalidMimeType = true; + stream.resume(); + return; + } + + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => { + fileBuffer = Buffer.concat(chunks); + }); + stream.on('limit', () => { + fileTruncated = true; + }); + }); + + busboy.on('finish', async () => { + // Validate file size + if (fileTruncated) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'File size must be less than 10MB' })); + resolve(); + return; + } + + // Validate MIME type + if (invalidMimeType) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Only JPG, PNG, and WebP images are allowed' })); + resolve(); + return; + } + + // Validate file was uploaded + if (!fileBuffer || !mimeType) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'No image file uploaded' })); + resolve(); + return; + } + + // Validate metadata was provided + if (!metadataJson) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Metadata is required' })); + resolve(); + return; + } + + // Parse metadata + let parsedMetadata: unknown; + try { + parsedMetadata = JSON.parse(metadataJson); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid metadata JSON' })); + resolve(); + return; + } + + // Validate metadata structure + if (!isValidOcrMetadata(parsedMetadata)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid metadata structure: expected layoutId, angle, and climb fields' })); + resolve(); + return; + } + + // Generate unique folder name + const folderName = generateFolderName(); + const ext = MIME_TO_EXT[mimeType] || 'png'; + + try { + // Upload image to S3 + const imageKey = `moonboard-ocr-test-data/${folderName}/image.${ext}`; + await uploadToS3(fileBuffer, imageKey, mimeType); + + // Prepare and upload metadata JSON + const fullMetadata = { + version: 1, + uploadedAt: new Date().toISOString(), + ...parsedMetadata, + imageMetadata: { + originalFilename: originalFilename || 'unknown', + mimeType, + fileSize: fileBuffer.length, + }, + }; + + const metadataBuffer = Buffer.from(JSON.stringify(fullMetadata, null, 2), 'utf-8'); + const metadataKey = `moonboard-ocr-test-data/${folderName}/parsed-result.json`; + await uploadToS3(metadataBuffer, metadataKey, 'application/json'); + + console.log(`[OCR Test Data] Uploaded test data to ${folderName}`); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, folder: folderName })); + } catch (uploadErr) { + // Log error but return success to not affect main flow + console.error('[OCR Test Data] Failed to upload:', uploadErr); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, skipped: true, reason: 'Upload failed' })); + } + resolve(); + }); + + busboy.on('error', (err: Error) => { + console.error('[OCR Test Data] Busboy error:', err); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + resolve(); + }); + + req.pipe(busboy); + }); +} diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index 55c25073..8d29878e 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -9,6 +9,7 @@ import { handleSessionJoin } from './handlers/join'; import { handleAvatarUpload } from './handlers/avatars'; import { handleStaticAvatar } from './handlers/static'; import { handleSyncCron } from './handlers/sync'; +import { handleOcrTestDataUpload } from './handlers/ocr-test-data'; import { createYogaInstance } from './graphql/yoga'; import { setupWebSocketServer } from './websocket/setup'; @@ -68,6 +69,12 @@ export async function startServer(): Promise<{ wss: WebSocketServer; httpServer: return; } + // OCR test data upload endpoint (handle OPTIONS for CORS preflight) + if (pathname === '/api/ocr-test-data' && (req.method === 'POST' || req.method === 'OPTIONS')) { + await handleOcrTestDataUpload(req, res); + return; + } + // Static avatar files if (pathname.startsWith('/static/avatars/')) { const fileName = pathname.slice('/static/avatars/'.length); @@ -125,6 +132,7 @@ export async function startServer(): Promise<{ wss: WebSocketServer; httpServer: console.log(` Join session: http://0.0.0.0:${PORT}/join/:sessionId`); console.log(` Avatar upload: http://0.0.0.0:${PORT}/api/avatars`); console.log(` Avatar files: http://0.0.0.0:${PORT}/static/avatars/`); + console.log(` OCR test data: http://0.0.0.0:${PORT}/api/ocr-test-data`); console.log(` Sync cron: http://0.0.0.0:${PORT}/sync-cron`); }); diff --git a/packages/backend/src/validation/schemas.ts b/packages/backend/src/validation/schemas.ts index 6bf10c87..88993997 100644 --- a/packages/backend/src/validation/schemas.ts +++ b/packages/backend/src/validation/schemas.ts @@ -148,10 +148,10 @@ export function validateInput(schema: z.ZodSchema, data: unknown, fieldNam // ============================================ /** - * Board name validation schema (kilter, tension) + * Board name validation schema (kilter, tension, moonboard) */ -export const BoardNameSchema = z.enum(['kilter', 'tension'], { - errorMap: () => ({ message: 'Board name must be kilter or tension' }), +export const BoardNameSchema = z.enum(['kilter', 'tension', 'moonboard'], { + errorMap: () => ({ message: 'Board name must be kilter, tension, or moonboard' }), }); // ============================================ diff --git a/packages/shared-schema/src/types.ts b/packages/shared-schema/src/types.ts index 86501cc5..6acb5ca6 100644 --- a/packages/shared-schema/src/types.ts +++ b/packages/shared-schema/src/types.ts @@ -105,7 +105,10 @@ export type EventsReplayResponse = { // Board Configuration Types // ============================================ -export type BoardName = 'kilter' | 'tension'; +export type BoardName = 'kilter' | 'tension' | 'moonboard'; + +// All supported board types - single source of truth +export const SUPPORTED_BOARDS: BoardName[] = ['kilter', 'tension', 'moonboard']; export type Grade = { difficultyId: number; 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..a4f5b98e 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 @@ -4,7 +4,6 @@ import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; import { parseBoardRouteParams } from '@/app/lib/url-utils'; import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; import CreateClimbForm from '@/app/components/create-climb/create-climb-form'; -import MoonBoardCreateClimbForm from '@/app/components/create-climb/moonboard-create-climb-form'; import { MOONBOARD_LAYOUTS, MOONBOARD_SETS, @@ -62,11 +61,12 @@ export default async function CreateClimbPage(props: CreateClimbPageProps) { const holdSetImages = getMoonBoardHoldSetImages(layoutInfo.layoutKey, parsedParams.set_ids); return ( - ); } @@ -76,8 +76,9 @@ 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..91170b53 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,14 @@ 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 { getBoardDetailsForBoard } from '@/app/lib/board-utils'; import { permanentRedirect } from 'next/navigation'; import ListLayoutClient from './layout-client'; + interface LayoutProps { params: Promise; } @@ -30,7 +31,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 +51,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..a38be47c 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,7 @@ import React from 'react'; import { notFound, permanentRedirect } from 'next/navigation'; -import { BoardRouteParametersWithUuid, SearchRequestPagination, BoardDetails } from '@/app/lib/types'; +import { BoardRouteParametersWithUuid, SearchRequestPagination, BoardDetails, Climb } from '@/app/lib/types'; import { parseBoardRouteParams, parsedRouteSearchParamsToSearchParams, @@ -11,8 +11,119 @@ import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; 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 { getBoardDetailsForBoard } from '@/app/lib/board-utils'; +import { MOONBOARD_HOLD_STATE_CODES, MOONBOARD_HOLD_STATES } 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 { boardClimbStats } from '@boardsesh/db/schema'; +import { eq, and, desc } from 'drizzle-orm'; +import { getGradeByDifficultyId } from '@/app/lib/board-data'; +import type { LitUpHoldsMap } from '@/app/components/board-renderer/types'; + + +// 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" + // Role codes: 42=start, 43=hand, 44=finish (see MOONBOARD_HOLD_STATE_CODES) + 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); + + // Determine which hold state to use based on role code + let holdStateKey: 'start' | 'hand' | 'finish'; + if (roleCode === MOONBOARD_HOLD_STATE_CODES.start) { + holdStateKey = 'start'; + } else if (roleCode === MOONBOARD_HOLD_STATE_CODES.finish) { + holdStateKey = 'finish'; + } else if (roleCode === MOONBOARD_HOLD_STATE_CODES.hand) { + holdStateKey = 'hand'; + } else { + // Unexpected role code - log a warning and default to 'hand' + console.warn( + `[MoonBoard] Unexpected role code ${roleCode} for hold ${holdId} in frames "${frames.slice(0, 50)}${frames.length > 50 ? '...' : ''}". Defaulting to HAND state.` + ); + holdStateKey = 'hand'; + } + const holdState = MOONBOARD_HOLD_STATES[holdStateKey]; + + map[holdId] = { + state: holdState.name, + color: holdState.color, + displayColor: holdState.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, + // Join with climb stats for grade info + displayDifficulty: boardClimbStats.displayDifficulty, + benchmarkDifficulty: boardClimbStats.benchmarkDifficulty, + qualityAverage: boardClimbStats.qualityAverage, + ascensionistCount: boardClimbStats.ascensionistCount, + }) + .from(climbs) + .leftJoin( + boardClimbStats, + and( + eq(boardClimbStats.boardType, 'moonboard'), + eq(boardClimbStats.climbUuid, climbs.uuid), + eq(boardClimbStats.angle, angle) + ) + ) + .where( + and( + eq(climbs.boardType, 'moonboard'), + eq(climbs.layoutId, layoutId), + eq(climbs.angle, angle) + ) + ) + .orderBy(desc(climbs.createdAt)) + .limit(limit); + + return results.map((row) => { + // Look up grade info from the shared BOULDER_GRADES constant. + // displayDifficulty is stored as double precision in the database but represents + // an integer difficulty_id. getGradeByDifficultyId rounds it to handle any + // floating point imprecision from database/ORM serialization. + const gradeInfo = row.displayDifficulty ? getGradeByDifficultyId(row.displayDifficulty) : undefined; + + return { + 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: row.ascensionistCount || 0, + // Format difficulty as "6a/V3" style for ClimbTitle to extract V-grade + difficulty: gradeInfo ? gradeInfo.difficulty_name : '', + // Set quality_average to "3" when we have a grade so ClimbTitle shows it + // (hasGrade check requires quality_average && quality_average !== '0') + quality_average: gradeInfo ? (row.qualityAverage ? String(row.qualityAverage) : '3') : '0', + stars: 0, + difficulty_error: '0', + litUpHoldsMap: parseMoonboardFrames(row.frames || ''), + benchmark_difficulty: row.benchmarkDifficulty ? String(row.benchmarkDifficulty) : null, + }; + }); +} export default async function DynamicResultsPage(props: { params: Promise; @@ -32,7 +143,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 +224,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/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/page.tsx index 8b77e22b..ecba5c6d 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/[climb_uuid]/page.tsx @@ -1,19 +1,20 @@ import React from 'react'; -import { BoardRouteParametersWithUuid } from '@/app/lib/types'; +import { BoardRouteParametersWithUuid, BoardDetails, ParsedBoardRouteParameters } from '@/app/lib/types'; import { parseBoardRouteParams, extractUuidFromSlug, constructPlayUrlWithSlugs } from '@/app/lib/url-utils'; import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; -import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; +import { getBoardDetailsForBoard } from '@/app/lib/board-utils'; import { getClimb } from '@/app/lib/data/queries'; import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; import PlayViewClient from './play-view-client'; import { Metadata } from 'next'; + export async function generateMetadata(props: { params: Promise }): Promise { const params = await props.params; try { const parsedParams = await parseBoardRouteParamsWithSlugs(params); - const [boardDetails, currentClimb] = await Promise.all([getBoardDetails(parsedParams), getClimb(parsedParams)]); + const [boardDetails, currentClimb] = await Promise.all([getBoardDetailsForBoard(parsedParams), getClimb(parsedParams)]); const climbName = currentClimb.name || `${boardDetails.board_name} Climb`; const climbGrade = currentClimb.difficulty || 'Unknown Grade'; @@ -97,7 +98,7 @@ export default async function PlayPage(props: { parsedParams = await parseBoardRouteParamsWithSlugs(params); } - const boardDetails = await getBoardDetails(parsedParams); + const boardDetails = getBoardDetailsForBoard(parsedParams); // Try to get the initial climb for SSR let initialClimb = null; diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/layout.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/layout.tsx index 783e7ada..38c67e94 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/layout.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/play/layout.tsx @@ -1,10 +1,11 @@ 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 } 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 PlayLayoutClient from './layout-client'; interface LayoutProps { @@ -28,7 +29,13 @@ export default async function PlayLayout(props: PropsWithChildren) parsedParams = await parseBoardRouteParamsWithSlugs(params); } - const boardDetails = await getBoardDetails(parsedParams); + // Use MoonBoard-specific details function for moonboard + let boardDetails: BoardDetails; + if (parsedParams.board_name === 'moonboard') { + boardDetails = getMoonBoardDetails(parsedParams); + } else { + boardDetails = getBoardDetails(parsedParams); + } return {children}; } diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/page.tsx index b29dab4b..3a70ad78 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/page.tsx @@ -1,14 +1,15 @@ import React from 'react'; import { notFound } from 'next/navigation'; import { getServerSession } from 'next-auth/next'; -import { BoardRouteParameters } from '@/app/lib/types'; -import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; +import { BoardRouteParameters, BoardDetails, ParsedBoardRouteParameters } from '@/app/lib/types'; +import { getBoardDetailsForBoard } from '@/app/lib/board-utils'; import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server'; import { Metadata } from 'next'; import { authOptions } from '@/app/lib/auth/auth-options'; import PlaylistViewContent from './playlist-view-content'; import styles from './playlist-view.module.css'; + type PlaylistRouteParameters = BoardRouteParameters & { playlist_uuid: string; }; @@ -25,7 +26,7 @@ export default async function PlaylistViewPage(props: { params: Promise { return { title: 'My Playlists | Boardsesh', @@ -19,7 +20,7 @@ export default async function PlaylistsListPage(props: { params: Promise diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx index b703097f..f2903659 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/view/[climb_uuid]/page.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { notFound, permanentRedirect } from 'next/navigation'; -import { BoardRouteParametersWithUuid } from '@/app/lib/types'; +import { BoardRouteParametersWithUuid, BoardDetails, ParsedBoardRouteParameters } from '@/app/lib/types'; import { getClimb } from '@/app/lib/data/queries'; -import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; +import { getBoardDetailsForBoard } from '@/app/lib/board-utils'; import ClimbCard from '@/app/components/climb-card/climb-card'; + import BetaVideos from '@/app/components/beta-videos/beta-videos'; import { LogbookSection } from '@/app/components/logbook/logbook-section'; import { @@ -29,7 +30,7 @@ export async function generateMetadata(props: { params: Promise { it('should accept valid board types', () => { expect(isValidBoardType('kilter')).toBe(true); expect(isValidBoardType('tension')).toBe(true); + expect(isValidBoardType('moonboard')).toBe(true); }); it('should reject invalid board types', () => { - expect(isValidBoardType('moonboard')).toBe(false); expect(isValidBoardType('invalid')).toBe(false); expect(isValidBoardType('')).toBe(false); expect(isValidBoardType(null)).toBe(false); @@ -44,7 +44,8 @@ describe('hold-classifications validation', () => { it('should have correct board types in constant', () => { expect(VALID_BOARD_TYPES).toContain('kilter'); expect(VALID_BOARD_TYPES).toContain('tension'); - expect(VALID_BOARD_TYPES.length).toBe(2); + expect(VALID_BOARD_TYPES).toContain('moonboard'); + expect(VALID_BOARD_TYPES.length).toBe(3); }); }); diff --git a/packages/web/app/api/internal/hold-classifications/validation.ts b/packages/web/app/api/internal/hold-classifications/validation.ts index 69cef980..d5cd3e07 100644 --- a/packages/web/app/api/internal/hold-classifications/validation.ts +++ b/packages/web/app/api/internal/hold-classifications/validation.ts @@ -1,6 +1,9 @@ -// Valid board types -export const VALID_BOARD_TYPES = ['kilter', 'tension'] as const; -export type ValidBoardType = (typeof VALID_BOARD_TYPES)[number]; +import { SUPPORTED_BOARDS } from '@/app/lib/board-data'; +import type { BoardName } from '@/app/lib/types'; + +// Valid board types - use the centralized SUPPORTED_BOARDS constant +export const VALID_BOARD_TYPES = SUPPORTED_BOARDS; +export type ValidBoardType = BoardName; // Valid hold types matching the database enum export const VALID_HOLD_TYPES = ['jug', 'sloper', 'pinch', 'crimp', 'pocket'] as const; @@ -20,7 +23,7 @@ export function parseIntSafe(value: string | null): number | null { * Validates board type against known boards */ export function isValidBoardType(value: unknown): value is ValidBoardType { - return typeof value === 'string' && VALID_BOARD_TYPES.includes(value as ValidBoardType); + return typeof value === 'string' && (VALID_BOARD_TYPES as readonly string[]).includes(value); } /** 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..edccf196 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,9 @@ // app/api/v1/[board_name]/proxy/saveClimb/route.ts -import { saveClimb } from '@/app/lib/api-wrappers/aurora/saveClimb'; +import { saveClimb, saveClimbStats } 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 { fontGradeToDifficultyId } from '@/app/lib/board-data'; import { NextResponse } from 'next/server'; import { z } from 'zod'; @@ -20,22 +23,75 @@ 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(), + is_draft: z.boolean().optional().default(false), + user_grade: z.string().optional(), // Font grade like "6A", "7B+" + is_benchmark: z.boolean().optional().default(false), + setter: z.string().optional(), // Setter username from OCR + }) + .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: validatedData.options.is_draft ?? false, + frames_count: 1, + frames_pace: 0, + setter_username: validatedData.options.setter, + }); + + // If a grade was provided, create climb stats + if (validatedData.options.user_grade) { + const difficultyId = fontGradeToDifficultyId(validatedData.options.user_grade); + if (difficultyId !== null) { + await saveClimbStats('moonboard', { + climbUuid: response.uuid, + angle: validatedData.options.angle, + displayDifficulty: difficultyId, + benchmarkDifficulty: validatedData.options.is_benchmark ? difficultyId : null, + }); + } + } + + 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/activity-feed/ascent-thumbnail.tsx b/packages/web/app/components/activity-feed/ascent-thumbnail.tsx index af4e5251..42a71ca8 100644 --- a/packages/web/app/components/activity-feed/ascent-thumbnail.tsx +++ b/packages/web/app/components/activity-feed/ascent-thumbnail.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { BoardDetails, BoardName } from '@/app/lib/types'; import BoardRenderer from '@/app/components/board-renderer/board-renderer'; import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; -import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; +import { getBoardDetailsForBoard } from '@/app/lib/board-utils'; import { getDefaultBoardConfig, getDefaultClimbViewPath } from '@/app/lib/default-board-configs'; import styles from './ascents-feed.module.css'; @@ -37,7 +37,7 @@ const AscentThumbnail: React.FC = ({ if (!config) return null; try { - return getBoardDetails({ + return getBoardDetailsForBoard({ board_name: boardName, layout_id: layoutId, size_id: config.sizeId, diff --git a/packages/web/app/components/activity-feed/ascents-feed.tsx b/packages/web/app/components/activity-feed/ascents-feed.tsx index c3ec8ea7..8916a72a 100644 --- a/packages/web/app/components/activity-feed/ascents-feed.tsx +++ b/packages/web/app/components/activity-feed/ascents-feed.tsx @@ -36,6 +36,11 @@ const layoutNames: Record = { 'tension-9': 'Tension Classic', 'tension-10': 'Tension 2 Mirror', 'tension-11': 'Tension 2 Spray', + 'moonboard-1': 'MoonBoard 2010', + 'moonboard-2': 'MoonBoard 2016', + 'moonboard-3': 'MoonBoard 2024', + 'moonboard-4': 'MoonBoard Masters 2017', + 'moonboard-5': 'MoonBoard Masters 2019', }; const getLayoutDisplayName = (boardType: string, layoutId: number | null): string => { diff --git a/packages/web/app/components/board-bluetooth-control/use-board-bluetooth.ts b/packages/web/app/components/board-bluetooth-control/use-board-bluetooth.ts index d72d832d..bc93c88f 100644 --- a/packages/web/app/components/board-bluetooth-control/use-board-bluetooth.ts +++ b/packages/web/app/components/board-bluetooth-control/use-board-bluetooth.ts @@ -42,7 +42,7 @@ export const convertToMirroredFramesString = (frames: string, holdsData: HoldRen }; interface UseBoardBluetoothOptions { - boardDetails: BoardDetails; + boardDetails?: BoardDetails; onConnectionChange?: (connected: boolean) => void; } @@ -77,7 +77,7 @@ export function useBoardBluetooth({ boardDetails, onConnectionChange }: UseBoard // Function to send frames string to the board const sendFramesToBoard = useCallback( async (frames: string, mirrored: boolean = false) => { - if (!characteristicRef.current || !frames) return; + if (!characteristicRef.current || !frames || !boardDetails) return; let framesToSend = frames; const placementPositions = getLedPlacements(boardDetails.board_name, boardDetails.layout_id, boardDetails.size_id); @@ -107,6 +107,11 @@ export function useBoardBluetooth({ boardDetails, onConnectionChange }: UseBoard return false; } + if (!boardDetails) { + console.error('Cannot connect to Bluetooth without board details'); + return false; + } + setLoading(true); try { diff --git a/packages/web/app/components/board-page/header.tsx b/packages/web/app/components/board-page/header.tsx index df15d8a9..39732339 100644 --- a/packages/web/app/components/board-page/header.tsx +++ b/packages/web/app/components/board-page/header.tsx @@ -113,18 +113,21 @@ export default function BoardSeshHeader({ boardDetails, angle }: BoardSeshHeader ? `/${boardDetails.board_name}/${generateLayoutSlug(boardDetails.layout_name)}/${generateSizeSlug(boardDetails.size_name)}/${generateSetSlug(boardDetails.set_names)}/${angle}/playlists` : null; + // Hide playlists and classify holds for moonboard (not yet supported) + const isMoonboard = boardDetails.board_name === 'moonboard'; + const userMenuItems: MenuProps['items'] = [ - ...(playlistsUrl ? [{ + ...(playlistsUrl && !isMoonboard ? [{ key: 'playlists', icon: , label: My Playlists, }] : []), - { + ...(!isMoonboard ? [{ key: 'classify-holds', icon: , label: 'Classify Holds', onClick: () => setShowHoldClassification(true), - }, + }] : []), { key: 'profile', icon: , @@ -157,18 +160,18 @@ export default function BoardSeshHeader({ boardDetails, angle }: BoardSeshHeader icon: , label: Create Climb, }] : []), - ...(session?.user && playlistsUrl ? [{ + ...(session?.user && playlistsUrl && !isMoonboard ? [{ key: 'playlists', icon: , label: My Playlists, }] : []), ...(session?.user ? [ - { + ...(!isMoonboard ? [{ key: 'classify-holds', icon: , label: 'Classify Holds', onClick: () => setShowHoldClassification(true), - }, + }] : []), { key: 'profile', icon: , diff --git a/packages/web/app/components/board-renderer/board-renderer.tsx b/packages/web/app/components/board-renderer/board-renderer.tsx index 1386cbb4..b0159b1f 100644 --- a/packages/web/app/components/board-renderer/board-renderer.tsx +++ b/packages/web/app/components/board-renderer/board-renderer.tsx @@ -4,6 +4,7 @@ import { BoardDetails } from '@/app/lib/types'; import BoardLitupHolds from './board-litup-holds'; import { LitUpHoldsMap } from './types'; import styles from './board-renderer.module.css'; +import MoonBoardRenderer from '../moonboard-renderer/moonboard-renderer'; export type BoardProps = { boardDetails: BoardDetails; @@ -19,6 +20,20 @@ export type BoardProps = { const BoardRenderer = React.memo( ({ boardDetails, thumbnail, maxHeight, fillHeight, litUpHoldsMap, mirrored, onHoldClick }: BoardProps) => { + // Delegate to MoonBoardRenderer for Moonboard (uses grid-based rendering) + if (boardDetails.board_name === 'moonboard' && boardDetails.layoutFolder) { + return ( + + ); + } + const { boardWidth, boardHeight, holdsData } = boardDetails; // When fillHeight is true, SVG fills container and uses preserveAspectRatio to fit diff --git a/packages/web/app/components/board-renderer/types.ts b/packages/web/app/components/board-renderer/types.ts index 1b853b31..f89b2d3f 100644 --- a/packages/web/app/components/board-renderer/types.ts +++ b/packages/web/app/components/board-renderer/types.ts @@ -61,10 +61,12 @@ export const HOLD_STATE_MAP: Record< 7: { name: 'FINISH', displayColor: '#FF0000', color: '#FF0000' }, 8: { name: 'FOOT', displayColor: '#FF00FF', color: '#FF00FF' }, }, - // MoonBoard uses simple codes: 1=start, 2=hand, 3=finish (no foot holds) + // MoonBoard hold states (no foot holds) + // Values inlined from moonboard-config.ts MOONBOARD_HOLD_STATE_CODES and MOONBOARD_HOLD_STATES + // to avoid computed property issues with bundlers moonboard: { - 1: { name: 'STARTING', displayColor: '#FF3333', color: '#FF0000' }, // Red - 2: { name: 'HAND', displayColor: '#4444FF', color: '#0000FF' }, // Blue - 3: { name: 'FINISH', displayColor: '#44FF44', color: '#00FF00' }, // Green + 42: { name: 'STARTING', color: '#00FF00', displayColor: '#44FF44' }, // start + 43: { name: 'HAND', color: '#0000FF', displayColor: '#4444FF' }, // hand + 44: { name: 'FINISH', color: '#FF0000', displayColor: '#FF3333' }, // finish }, }; diff --git a/packages/web/app/components/climb-actions/actions/fork-action.tsx b/packages/web/app/components/climb-actions/actions/fork-action.tsx index 983abe23..f45b3077 100644 --- a/packages/web/app/components/climb-actions/actions/fork-action.tsx +++ b/packages/web/app/components/climb-actions/actions/fork-action.tsx @@ -20,7 +20,9 @@ export function ForkAction({ className, onComplete, }: ClimbActionProps): ClimbActionResult { - const canFork = !!(boardDetails.layout_name && boardDetails.size_name && boardDetails.set_names); + // Fork is not supported for moonboard yet + const isMoonboard = boardDetails.board_name === 'moonboard'; + const canFork = !isMoonboard && !!(boardDetails.layout_name && boardDetails.size_name && boardDetails.set_names); const url = canFork ? constructCreateClimbUrl( diff --git a/packages/web/app/components/climb-actions/actions/playlist-action.tsx b/packages/web/app/components/climb-actions/actions/playlist-action.tsx index 41a9415d..94c6f3db 100644 --- a/packages/web/app/components/climb-actions/actions/playlist-action.tsx +++ b/packages/web/app/components/climb-actions/actions/playlist-action.tsx @@ -30,6 +30,9 @@ export function PlaylistAction({ className, onComplete, }: ClimbActionProps): ClimbActionResult { + // Playlists not supported for moonboard yet + const isMoonboard = boardDetails.board_name === 'moonboard'; + const [showAuthModal, setShowAuthModal] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false); @@ -358,7 +361,7 @@ export function PlaylistAction({ expandedContent, menuItem, key: 'playlist', - available: true, + available: !isMoonboard, }; } diff --git a/packages/web/app/components/climb-card/climb-card.tsx b/packages/web/app/components/climb-card/climb-card.tsx index d808a7cc..d626416e 100644 --- a/packages/web/app/components/climb-card/climb-card.tsx +++ b/packages/web/app/components/climb-card/climb-card.tsx @@ -64,6 +64,18 @@ function ClimbCardWithActions({ const cover = ; const cardTitle = ; + // Build exclude list - MoonBoard doesn't have a view details page yet + const excludeActions: ('tick' | 'openInApp' | 'mirror' | 'share' | 'addToList' | 'viewDetails')[] = [ + 'tick', + 'openInApp', + 'mirror', + 'share', + 'addToList', + ]; + if (boardDetails.board_name === 'moonboard') { + excludeActions.push('viewDetails'); + } + return (
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..fa2d0b86 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; } @@ -167,6 +205,11 @@ font-size: 12px; } +/* MoonBoard-specific grade field */ +.settingsGradeField { + width: 100%; +} + /* Hold counts bar at bottom */ .holdCountsBar { display: flex; diff --git a/packages/web/app/components/create-climb/create-climb-form.tsx b/packages/web/app/components/create-climb/create-climb-form.tsx index 9e3c362c..d172e166 100644 --- a/packages/web/app/components/create-climb/create-climb-form.tsx +++ b/packages/web/app/components/create-climb/create-climb-form.tsx @@ -1,18 +1,26 @@ 'use client'; -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Input, Switch, Button, Typography, Tag, Alert, Flex, Slider, Tooltip } from 'antd'; -import type { InputRef } from 'antd'; -import { SettingOutlined, EditOutlined, CheckOutlined, CloseOutlined, FireOutlined } from '@ant-design/icons'; -import { useRouter } from 'next/navigation'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { Input, Switch, Button, Typography, Tag, Alert, Flex, Slider, Tooltip, Space, Upload, message, Select } from 'antd'; +import { SettingOutlined, CloseOutlined, FireOutlined, ArrowLeftOutlined, SaveOutlined, LoginOutlined, UploadOutlined, LoadingOutlined, ImportOutlined } from '@ant-design/icons'; +import { useRouter, usePathname } from 'next/navigation'; +import Link from 'next/link'; import { track } from '@vercel/analytics'; +import { useSession } from 'next-auth/react'; import BoardRenderer from '../board-renderer/board-renderer'; +import MoonBoardRenderer from '../moonboard-renderer/moonboard-renderer'; import { useBoardProvider } from '../board-provider/board-provider-context'; import { useCreateClimb } from './use-create-climb'; +import { useMoonBoardCreateClimb } from './use-moonboard-create-climb'; import { useBoardBluetooth } from '../board-bluetooth-control/use-board-bluetooth'; import { BoardDetails } from '@/app/lib/types'; import { constructClimbListWithSlugs } from '@/app/lib/url-utils'; import { convertLitUpHoldsStringToMap } from '../board-renderer/util'; +import { holdIdToCoordinate, MOONBOARD_GRADES, MOONBOARD_ANGLES } from '@/app/lib/moonboard-config'; +import { getFontGradeColor } from '@/app/lib/grade-colors'; +import { themeTokens } from '@/app/theme/theme-config'; +import { parseScreenshot } from '@boardsesh/moonboard-ocr/browser'; +import { convertOcrHoldsToMap } from '@/app/lib/moonboard-climbs-db'; import AuthModal from '../auth/auth-modal'; import { useCreateClimbContext } from './create-climb-context'; import CreateClimbHeatmapOverlay from './create-climb-heatmap-overlay'; @@ -27,116 +35,172 @@ interface CreateClimbFormValues { isDraft: boolean; } +type BoardType = 'aurora' | 'moonboard'; + interface CreateClimbFormProps { - boardDetails: BoardDetails; + boardType: BoardType; angle: number; + // Aurora-specific + boardDetails?: BoardDetails; forkFrames?: string; forkName?: string; + // MoonBoard-specific + layoutFolder?: string; + layoutId?: number; + holdSetImages?: string[]; } -export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkName }: CreateClimbFormProps) { +export default function CreateClimbForm({ + boardType, + angle, + boardDetails, + forkFrames, + forkName, + layoutFolder, + layoutId, + holdSetImages, +}: CreateClimbFormProps) { const router = useRouter(); + const pathname = usePathname(); + const { data: session } = useSession(); + + // Aurora-specific hooks const { isAuthenticated, saveClimb } = useBoardProvider(); - // Convert fork frames to initial holds map if provided + // Determine which auth check to use based on board type + const isLoggedIn = boardType === 'aurora' ? isAuthenticated : !!session?.user?.id; + + // Convert fork frames to initial holds map if provided (Aurora only) const initialHoldsMap = useMemo(() => { - if (!forkFrames) return undefined; + if (boardType !== 'aurora' || !forkFrames || !boardDetails) return undefined; const framesMap = convertLitUpHoldsStringToMap(forkFrames, boardDetails.board_name); - // Get the first frame (frame 0) - most climbs have a single frame return framesMap[0] ?? undefined; - }, [forkFrames, boardDetails.board_name]); + }, [boardType, forkFrames, boardDetails]); + + // Aurora hold management + const auroraClimb = useCreateClimb(boardDetails?.board_name || 'kilter', { initialHoldsMap }); + // MoonBoard hold management + const moonboardClimb = useMoonBoardCreateClimb(); + + // Use the appropriate hook values based on board type const { litUpHoldsMap, - handleHoldClick: originalHandleHoldClick, - generateFramesString, + handleHoldClick: baseHandleHoldClick, startingCount, finishCount, totalHolds, isValid, - resetHolds: originalResetHolds, - } = useCreateClimb(boardDetails.board_name, { initialHoldsMap }); + resetHolds: baseResetHolds, + } = boardType === 'aurora' ? auroraClimb : moonboardClimb; + + const handCount = boardType === 'moonboard' ? moonboardClimb.handCount : 0; + const generateFramesString = boardType === 'aurora' ? auroraClimb.generateFramesString : undefined; + const setLitUpHoldsMap = boardType === 'moonboard' ? moonboardClimb.setLitUpHoldsMap : undefined; + + // Bluetooth for Aurora boards + const { isConnected, sendFramesToBoard } = useBoardBluetooth({ + boardDetails: boardType === 'aurora' ? boardDetails : undefined + }); - const { isConnected, sendFramesToBoard } = useBoardBluetooth({ boardDetails }); const createClimbContext = useCreateClimbContext(); + // Form state const [isSaving, setIsSaving] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); const [pendingFormValues, setPendingFormValues] = useState(null); + + // Aurora-specific state const [showHeatmap, setShowHeatmap] = useState(false); const [heatmapOpacity, setHeatmapOpacity] = useState(0.7); + const [isDraft, setIsDraft] = useState(false); - // Editable title state + // MoonBoard-specific state + const [isOcrProcessing, setIsOcrProcessing] = useState(false); + const [ocrError, setOcrError] = useState(null); + const [ocrWarnings, setOcrWarnings] = useState([]); + const [userGrade, setUserGrade] = useState(undefined); + const [isBenchmark, setIsBenchmark] = useState(false); + const [selectedAngle, setSelectedAngle] = useState(angle); + + // Common state const [climbName, setClimbName] = useState(forkName ? `${forkName} fork` : ''); const [description, setDescription] = useState(''); - const [isDraft, setIsDraft] = useState(false); - const [isEditingTitle, setIsEditingTitle] = useState(false); - const [editingName, setEditingName] = useState(''); const [showSettingsPanel, setShowSettingsPanel] = useState(false); - const titleInputRef = useRef(null); - // Send frames to board whenever litUpHoldsMap changes and we're connected + // Construct the bulk import URL (MoonBoard only) + const bulkImportUrl = pathname.replace(/\/create$/, '/import'); + + // Send frames to board whenever litUpHoldsMap changes (Aurora only) useEffect(() => { - if (isConnected) { + if (boardType === 'aurora' && isConnected && generateFramesString) { const frames = generateFramesString(); sendFramesToBoard(frames); } - }, [litUpHoldsMap, isConnected, generateFramesString, sendFramesToBoard]); + }, [boardType, litUpHoldsMap, isConnected, generateFramesString, sendFramesToBoard]); - // Wrap handleHoldClick to also send to board after state updates + // Wrap handleHoldClick const handleHoldClick = useCallback( (holdId: number) => { - originalHandleHoldClick(holdId); - // The useEffect above will handle sending to board after state updates + baseHandleHoldClick(holdId); }, - [originalHandleHoldClick], + [baseHandleHoldClick], ); // Wrap resetHolds to also clear the board const resetHolds = useCallback(() => { - originalResetHolds(); - // Send empty frames to clear the board - if (isConnected) { + baseResetHolds(); + if (boardType === 'aurora' && isConnected) { sendFramesToBoard(''); } - }, [originalResetHolds, isConnected, sendFramesToBoard]); - - // Title editing handlers - const handleStartEditTitle = useCallback(() => { - setEditingName(climbName); - setIsEditingTitle(true); - // Focus the input after it renders - setTimeout(() => titleInputRef.current?.focus(), 0); - }, [climbName]); - - const handleSaveTitle = useCallback(() => { - const trimmedName = editingName.trim(); - // Only update if we have a valid name, otherwise keep the original - if (trimmedName) { - setClimbName(trimmedName); - } - // Always exit edit mode and clear the editing state - setIsEditingTitle(false); - setEditingName(''); - }, [editingName]); - - const handleCancelEditTitle = useCallback(() => { - setIsEditingTitle(false); - setEditingName(''); - }, []); + }, [boardType, baseResetHolds, isConnected, sendFramesToBoard]); - const handleTitleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSaveTitle(); - } else if (e.key === 'Escape') { - handleCancelEditTitle(); + // MoonBoard OCR import + const handleOcrImport = useCallback(async (file: File) => { + if (boardType !== 'moonboard' || !setLitUpHoldsMap) return; + + setIsOcrProcessing(true); + setOcrError(null); + setOcrWarnings([]); + + try { + const result = await parseScreenshot(file); + + if (!result.success || !result.climb) { + setOcrError(result.error || 'Failed to parse screenshot'); + return; } - }, - [handleSaveTitle, handleCancelEditTitle], - ); - const doSaveClimb = useCallback(async () => { + const climb = result.climb; + const warnings = [...result.warnings]; + + // Check angle mismatch + if (climb.angle !== angle) { + warnings.push(`Screenshot is for ${climb.angle}° but current page is ${angle}°. Holds imported anyway.`); + } + + setOcrWarnings(warnings); + + // Convert OCR holds to form state + const newHoldsMap = convertOcrHoldsToMap(climb.holds); + setLitUpHoldsMap(newHoldsMap); + + // Populate fields from OCR + if (climb.name) setClimbName(climb.name); + if (climb.userGrade) setUserGrade(climb.userGrade); + if (climb.isBenchmark) setIsBenchmark(true); + if (climb.setter) setDescription(`Setter: ${climb.setter}`); + } catch (error) { + setOcrError(error instanceof Error ? error.message : 'Unknown error during OCR'); + } finally { + setIsOcrProcessing(false); + } + }, [boardType, angle, setLitUpHoldsMap]); + + // Save climb - Aurora + const doSaveAuroraClimb = useCallback(async () => { + if (!boardDetails || !generateFramesString) return; + setIsSaving(true); try { @@ -159,7 +223,6 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN holdCount: totalHolds, }); - // Navigate back to the climb list const listUrl = constructClimbListWithSlugs( boardDetails.board_name, boardDetails.layout_name || '', @@ -177,63 +240,123 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN } finally { setIsSaving(false); } - }, [generateFramesString, saveClimb, boardDetails, climbName, description, isDraft, angle, totalHolds, router]); + }, [boardDetails, generateFramesString, saveClimb, climbName, description, isDraft, angle, totalHolds, router]); + + // Save climb - MoonBoard + const doSaveMoonBoardClimb = useCallback(async () => { + const userId = session?.user?.id; + if (!layoutId || !userId) return; + + setIsSaving(true); + + try { + // Convert holds to coordinate format for storage + const holds = { + start: Object.entries(litUpHoldsMap) + .filter(([, hold]) => hold.state === 'STARTING') + .map(([id]) => holdIdToCoordinate(Number(id))), + hand: Object.entries(litUpHoldsMap) + .filter(([, hold]) => hold.state === 'HAND') + .map(([id]) => holdIdToCoordinate(Number(id))), + finish: Object.entries(litUpHoldsMap) + .filter(([, hold]) => hold.state === 'FINISH') + .map(([id]) => holdIdToCoordinate(Number(id))), + }; + + 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: userId, + name: climbName, + description: description || '', + holds, + angle: selectedAngle, + is_draft: isDraft, + user_grade: userGrade, + is_benchmark: isBenchmark, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to save climb'); + } + + message.success('Climb saved to database!'); + + const listUrl = pathname.replace(/\/create$/, '/list'); + router.push(listUrl); + } catch (error) { + console.error('Failed to save climb:', error); + message.error(error instanceof Error ? error.message : 'Failed to save climb. Please try again.'); + } finally { + setIsSaving(false); + } + }, [layoutId, session, litUpHoldsMap, climbName, description, userGrade, isBenchmark, isDraft, selectedAngle, pathname, router]); const handlePublish = useCallback(async () => { if (!isValid || !climbName.trim()) { return; } - if (!isAuthenticated) { - // Store the form values and show auth modal - setPendingFormValues({ name: climbName, description, isDraft }); - setShowAuthModal(true); + if (!isLoggedIn) { + if (boardType === 'aurora') { + setPendingFormValues({ name: climbName, description, isDraft }); + setShowAuthModal(true); + } return; } - await doSaveClimb(); - }, [isValid, climbName, isAuthenticated, description, isDraft, doSaveClimb]); + if (boardType === 'aurora') { + await doSaveAuroraClimb(); + } else { + await doSaveMoonBoardClimb(); + } + }, [boardType, isValid, climbName, isLoggedIn, description, isDraft, doSaveAuroraClimb, doSaveMoonBoardClimb]); const handleAuthSuccess = async () => { - // After successful auth, check if they have Aurora credentials linked - // If not, they'll see the message in the form - // If yes and we have pending form values, we need to wait for the credentials to load if (pendingFormValues) { - // Give time for the board provider to refresh credentials setTimeout(async () => { - // The component will re-render with new auth state - // User needs to click save again after linking their account setPendingFormValues(null); }, 1000); } }; const handleCancel = useCallback(() => { - const listUrl = constructClimbListWithSlugs( - boardDetails.board_name, - boardDetails.layout_name || '', - boardDetails.size_name || '', - boardDetails.size_description, - boardDetails.set_names || [], - angle, - ); - router.push(listUrl); - }, [boardDetails, angle, router]); + if (boardType === 'aurora' && boardDetails) { + const listUrl = constructClimbListWithSlugs( + boardDetails.board_name, + boardDetails.layout_name || '', + boardDetails.size_name || '', + boardDetails.size_description, + boardDetails.set_names || [], + angle, + ); + router.push(listUrl); + } else { + router.back(); + } + }, [boardType, boardDetails, angle, router]); - const canSave = isAuthenticated && isValid && climbName.trim().length > 0; + const canSave = isLoggedIn && isValid && climbName.trim().length > 0; const handleToggleSettings = useCallback(() => { setShowSettingsPanel((prev) => !prev); }, []); const handleToggleHeatmap = useCallback(() => { + if (boardType !== 'aurora' || !boardDetails) return; setShowHeatmap((prev) => { track(`Create Climb Heatmap ${!prev ? 'Shown' : 'Hidden'}`, { boardLayout: boardDetails.layout_name || '', }); return !prev; }); - }, [boardDetails.layout_name]); + }, [boardType, boardDetails]); // Register actions with context for header to use useEffect(() => { @@ -258,10 +381,91 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN } }, [createClimbContext, isSaving]); + // Render save/login button + const renderSaveButton = () => { + if (boardType === 'aurora') { + if (!isAuthenticated) { + return ( + + ); + } + return ( + + ); + } + + // MoonBoard + if (!session?.user) { + return ( + + + + ); + } + return ( + + ); + }; + return (
- {/* Auth alerts */} - {!isAuthenticated && ( + {/* Unified Header */} +
+ + setClimbName(e.target.value)} + /> + {/* MoonBoard: Show grade in header like climb card */} + {boardType === 'moonboard' && userGrade && ( + + {userGrade} + + )} +
+ + {/* Auth alert for both board types */} + {!isLoggedIn && ( setShowAuthModal(true)}> - Sign In - + boardType === 'aurora' ? ( + + ) : ( + + + + ) } /> )} - {/* Main Content - matches play view layout */} + {/* MoonBoard OCR errors */} + {boardType === 'moonboard' && ocrError && ( + setOcrError(null)} + className={styles.alertBanner} + /> + )} + + {boardType === 'moonboard' && ocrWarnings.length > 0 && ( +
{w}
)} + type="warning" + showIcon + closable + onClose={() => setOcrWarnings([])} + className={styles.alertBanner} + /> + )} +
- {/* Title section - same position as play view */} + {/* Controls bar with draft toggle (all boards) and heatmap (Aurora only) */}
- - {/* Left side: Editable Name and draft toggle stacked */} - - {/* Row 1: Editable Title */} - {isEditingTitle ? ( - - setEditingName(e.target.value)} - onKeyDown={handleTitleKeyDown} - onBlur={handleSaveTitle} - maxLength={100} - placeholder="Enter climb name" - size="small" - className={styles.titleInput} - /> -
- {/* Board section - fills remaining space like play view */} + {/* Board Section */}
- - + {boardType === 'aurora' && boardDetails ? ( + <> + + + + ) : boardType === 'moonboard' && layoutFolder && holdSetImages ? ( + + ) : null}
{/* Settings overlay panel */} @@ -408,6 +598,46 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN Climb Settings
+ {/* MoonBoard-specific: Angle, Grade and Benchmark */} + {boardType === 'moonboard' && ( + <> +
+ + Angle + + ({ value: g.value, label: g.label }))} + className={styles.settingsGradeField} + allowClear + /> +
+
+ + + Benchmark + +
+ + )} + {/* Common: Description */}
Description (optional) @@ -428,22 +658,72 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN {/* Hold counts bar at bottom */}
- 0 ? 'green' : 'default'}>Starting: {startingCount}/2 - 0 ? 'magenta' : 'default'}>Finish: {finishCount}/2 - 0 ? 'blue' : 'default'}>Total: {totalHolds} - + + {boardType === 'aurora' ? ( + <> + 0 ? 'green' : 'default'}>Starting: {startingCount}/2 + 0 ? 'magenta' : 'default'}>Finish: {finishCount}/2 + 0 ? 'blue' : 'default'}>Total: {totalHolds} + + ) : ( + <> + 0 ? 'red' : 'default'}>Start: {startingCount}/2 + 0 ? 'blue' : 'default'}>Hand: {handCount} + 0 ? 'green' : 'default'}>Finish: {finishCount}/2 + 0 ? 'purple' : 'default'}>Total: {totalHolds} + + )} + + + {totalHolds > 0 && ( + + )} + {/* MoonBoard-only: Import buttons */} + {boardType === 'moonboard' && ( + <> + { + handleOcrImport(file); + return false; + }} + disabled={isOcrProcessing} + > + + + + + + + )} +
- setShowAuthModal(false)} - onSuccess={handleAuthSuccess} - title="Sign in to save your climb" - description="Create an account or sign in to save your climb to the board." - /> + {/* MoonBoard validation hint */} + {boardType === 'moonboard' && !isValid && totalHolds > 0 && ( +
+ + A valid climb needs at least 1 start hold and 1 finish hold + +
+ )} + + {/* Auth modal (Aurora only) */} + {boardType === 'aurora' && ( + setShowAuthModal(false)} + onSuccess={handleAuthSuccess} + title="Sign in to save your climb" + description="Create an account or sign in to save your climb to the board." + /> + )}
); } 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 deleted file mode 100644 index 347c5490..00000000 --- a/packages/web/app/components/create-climb/moonboard-create-climb-form.tsx +++ /dev/null @@ -1,305 +0,0 @@ -'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 { useRouter, usePathname } from 'next/navigation'; -import Link from 'next/link'; -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 styles from './create-climb-form.module.css'; - -const { TextArea } = Input; -const { Text } = Typography; - -interface MoonBoardCreateClimbFormValues { - name: string; - description: string; -} - -interface MoonBoardCreateClimbFormProps { - layoutFolder: string; - layoutName: string; - holdSetImages: string[]; - angle: number; -} - -export default function MoonBoardCreateClimbForm({ - layoutFolder, - layoutName, - holdSetImages, - angle, -}: MoonBoardCreateClimbFormProps) { - const router = useRouter(); - const pathname = usePathname(); - - // Construct the bulk import URL (replace /create with /import) - const bulkImportUrl = pathname.replace(/\/create$/, '/import'); - - const { - litUpHoldsMap, - setLitUpHoldsMap, - handleHoldClick, - startingCount, - finishCount, - handCount, - totalHolds, - isValid, - resetHolds, - } = useMoonBoardCreateClimb(); - - const [form] = Form.useForm(); - const [isSaving, setIsSaving] = useState(false); - const [saveSuccess, setSaveSuccess] = useState(false); - - // OCR import state - const [isOcrProcessing, setIsOcrProcessing] = useState(false); - const [ocrError, setOcrError] = useState(null); - const [ocrWarnings, setOcrWarnings] = useState([]); - - const handleOcrImport = async (file: File) => { - setIsOcrProcessing(true); - setOcrError(null); - setOcrWarnings([]); - - try { - const result = await parseScreenshot(file); - - if (!result.success || !result.climb) { - setOcrError(result.error || 'Failed to parse screenshot'); - return; - } - - const climb = result.climb; - const warnings = [...result.warnings]; - - // Check angle mismatch - if (climb.angle !== angle) { - warnings.push(`Screenshot is for ${climb.angle}° but current page is ${angle}°. Holds imported anyway.`); - } - - setOcrWarnings(warnings); - - // Convert OCR holds to form state - const newHoldsMap = convertOcrHoldsToMap(climb.holds); - setLitUpHoldsMap(newHoldsMap); - - // Build description with setter info - const descriptionParts: string[] = []; - if (climb.setter) descriptionParts.push(`Setter: ${climb.setter}`); - if (climb.userGrade) descriptionParts.push(`Grade: ${climb.userGrade}`); - if (climb.isBenchmark) descriptionParts.push('(Benchmark)'); - - // Populate form fields - form.setFieldsValue({ - name: climb.name || '', - description: descriptionParts.join('\n'), - }); - } catch (error) { - setOcrError(error instanceof Error ? error.message : 'Unknown error during OCR'); - } finally { - setIsOcrProcessing(false); - } - }; - - const handleSubmit = async (values: MoonBoardCreateClimbFormValues) => { - if (!isValid) { - return; - } - - setIsSaving(true); - - try { - // Convert holds to coordinate format for storage - const holds = { - start: Object.entries(litUpHoldsMap) - .filter(([, hold]) => hold.type === 'start') - .map(([id]) => holdIdToCoordinate(Number(id))), - hand: Object.entries(litUpHoldsMap) - .filter(([, hold]) => hold.type === 'hand') - .map(([id]) => holdIdToCoordinate(Number(id))), - finish: Object.entries(litUpHoldsMap) - .filter(([, hold]) => hold.type === 'finish') - .map(([id]) => holdIdToCoordinate(Number(id))), - }; - - const climbData = { - name: values.name, - description: values.description || '', - holds, - angle, - layoutFolder, - createdAt: new Date().toISOString(), - }; - - await saveMoonBoardClimb(climbData); - - message.success('Climb saved successfully!'); - setSaveSuccess(true); - - // Reset the form and holds - form.resetFields(); - resetHolds(); - - // Show success briefly then allow another climb to be created - setTimeout(() => { - setSaveSuccess(false); - }, 3000); - } catch (error) { - console.error('Failed to save climb:', error); - message.error('Failed to save climb. Please try again.'); - } finally { - setIsSaving(false); - } - }; - - const handleCancel = () => { - router.back(); - }; - - return ( -
- } - className={styles.betaBanner} - banner - /> - - {saveSuccess && ( - - )} - - {ocrError && ( - setOcrError(null)} - className={styles.betaBanner} - /> - )} - - {ocrWarnings.length > 0 && ( -
{w}
)} - type="warning" - showIcon - closable - onClose={() => setOcrWarnings([])} - className={styles.betaBanner} - /> - )} - -
- {/* 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 */} -
-
- - - - - -