From a85d93bfa82a01179754c611d671a0d916806dd6 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sat, 24 Jan 2026 10:02:47 +1100 Subject: [PATCH 01/21] feat: Enable Moonboard climb saving to PostgreSQL database - Add frames encoding utility (`encodeMoonBoardHoldsToFrames`) to convert holds to the `p{holdId}r{roleCode}` format used by the database - Update saveClimb API to accept Moonboard requests with holds format and convert to frames before saving - Update MoonBoardCreateClimbForm to require authentication and save climbs via the API instead of IndexedDB - Update MoonBoardBulkImport similarly for batch saves - Add direct database query for Moonboard climbs in list page since GraphQL backend doesn't support Moonboard yet - Skip GraphQL search and playlist operations for Moonboard to prevent errors - Add getMoonBoardDetails helper throughout codebase to handle Moonboard board details separately from Aurora boards - Enable MOONBOARD_ENABLED feature flag by default - Remove IndexedDB saving functions (no longer needed) - Restructure create form UI with header containing save button Co-Authored-By: Claude Opus 4.5 --- .../[set_ids]/[angle]/create/page.tsx | 2 +- .../[set_ids]/[angle]/import/page.tsx | 1 + .../[set_ids]/[angle]/list/layout.tsx | 18 +- .../[size_id]/[set_ids]/[angle]/list/page.tsx | 118 +++++++- .../v1/[board_name]/proxy/saveClimb/route.ts | 52 +++- .../create-climb/create-climb-form.module.css | 52 +++- .../moonboard-create-climb-form.tsx | 256 +++++++++--------- .../components/graphql-queue/QueueContext.tsx | 6 +- .../moonboard-bulk-import.tsx | 97 +++++-- .../hooks/use-queue-data-fetching.tsx | 7 + .../consolidated-board-config.tsx | 25 +- packages/web/app/lib/moonboard-climbs-db.ts | 108 -------- packages/web/app/lib/moonboard-config.ts | 41 ++- 13 files changed, 481 insertions(+), 302 deletions(-) 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..67629003 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 } 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,6 @@ export default function MoonBoardCreateClimbForm({ const [form] = Form.useForm(); const [isSaving, setIsSaving] = useState(false); - const [saveSuccess, setSaveSuccess] = useState(false); // OCR import state const [isOcrProcessing, setIsOcrProcessing] = useState(false); @@ -110,6 +111,11 @@ export default function MoonBoardCreateClimbForm({ return; } + if (!session?.user?.id) { + message.error('Please log in to save climbs'); + return; + } + setIsSaving(true); try { @@ -126,31 +132,34 @@ export default function MoonBoardCreateClimbForm({ .map(([id]) => holdIdToCoordinate(Number(id))), }; - const climbData = { - name: values.name, - description: values.description || '', - holds, - angle, - layoutFolder, - createdAt: new Date().toISOString(), - }; - - await saveMoonBoardClimb(climbData); + 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, + }, + }), + }); - message.success('Climb saved successfully!'); - setSaveSuccess(true); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to save climb'); + } - // 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); } @@ -162,144 +171,131 @@ export default function MoonBoardCreateClimbForm({ 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} /> )}
{/* 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} > - - - - - -