From f4ca9a563e90a049cccda03c309ba031560ecc4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Dec 2025 12:49:09 +0000 Subject: [PATCH 1/8] feat: Add draft climb persistence with IndexedDB - Create draft-climbs-db.ts for IndexedDB persistence - Add DraftsProvider context for managing draft state - Modify create-climb-form to auto-save drafts with UUID - Add DraftsDrawer component for viewing/managing drafts --- .../create-climb/create-climb-form.tsx | 105 +++++++++- .../create-climb/use-create-climb.ts | 1 + .../app/components/drafts/drafts-context.tsx | 133 ++++++++++++ .../app/components/drafts/drafts-drawer.tsx | 137 +++++++++++++ .../providers/persistent-session-wrapper.tsx | 12 +- packages/web/app/lib/draft-climbs-db.ts | 192 ++++++++++++++++++ 6 files changed, 573 insertions(+), 7 deletions(-) create mode 100644 packages/web/app/components/drafts/drafts-context.tsx create mode 100644 packages/web/app/components/drafts/drafts-drawer.tsx create mode 100644 packages/web/app/lib/draft-climbs-db.ts 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 1db7c169..a7906d05 100644 --- a/packages/web/app/components/create-climb/create-climb-form.tsx +++ b/packages/web/app/components/create-climb/create-climb-form.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { Input, Switch, Button, Typography, Tag, Alert, Flex } from 'antd'; import type { InputRef } from 'antd'; import { SettingOutlined, EditOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { track } from '@vercel/analytics'; import BoardRenderer from '../board-renderer/board-renderer'; import { useBoardProvider } from '../board-provider/board-provider-context'; @@ -15,7 +15,8 @@ import { constructClimbListWithSlugs } from '@/app/lib/url-utils'; import { convertLitUpHoldsStringToMap } from '../board-renderer/util'; import AuthModal from '../auth/auth-modal'; import { useCreateClimbContext } from './create-climb-context'; -import { themeTokens } from '@/app/theme/theme-config'; +import { useDrafts, DraftClimb } from '../drafts/drafts-context'; +import { getDraftClimb } from '@/app/lib/draft-climbs-db'; import styles from './create-climb-form.module.css'; const { Text } = Typography; @@ -36,7 +37,15 @@ interface CreateClimbFormProps { export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkName }: CreateClimbFormProps) { const router = useRouter(); + const searchParams = useSearchParams(); + const draftIdFromUrl = searchParams.get('draftId'); const { isAuthenticated, saveClimb } = useBoardProvider(); + const { createDraft, updateDraft, deleteDraft } = useDrafts(); + + const [currentDraft, setCurrentDraft] = useState(null); + const [isLoadingDraft, setIsLoadingDraft] = useState(!!draftIdFromUrl); + const autoSaveTimerRef = useRef | null>(null); + const hasInitializedRef = useRef(false); // Convert fork frames to initial holds map if provided const initialHoldsMap = useMemo(() => { @@ -55,6 +64,7 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN totalHolds, isValid, resetHolds: originalResetHolds, + setLitUpHoldsMap, } = useCreateClimb(boardDetails.board_name, { initialHoldsMap }); const { isConnected, sendFramesToBoard } = useBoardBluetooth({ boardDetails }); @@ -73,6 +83,86 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN const [showSettingsPanel, setShowSettingsPanel] = useState(false); const titleInputRef = useRef(null); + // Load existing draft or create a new one on mount + useEffect(() => { + if (hasInitializedRef.current) return; + hasInitializedRef.current = true; + + const initializeDraft = async () => { + if (draftIdFromUrl) { + // Resuming an existing draft + const existingDraft = await getDraftClimb(draftIdFromUrl); + if (existingDraft) { + setCurrentDraft(existingDraft); + // Restore form values + setClimbName(existingDraft.name); + setDescription(existingDraft.description); + setIsDraft(existingDraft.isDraft); + // Restore holds map + if (Object.keys(existingDraft.litUpHoldsMap).length > 0) { + setLitUpHoldsMap(existingDraft.litUpHoldsMap); + } + } + setIsLoadingDraft(false); + } else { + // Create a new draft immediately + const newDraft = await createDraft( + boardDetails.board_name, + boardDetails.layout_id, + boardDetails.size_id, + boardDetails.set_ids, + angle, + ); + setCurrentDraft(newDraft); + // Update URL with draft ID (without navigation) + const url = new URL(window.location.href); + url.searchParams.set('draftId', newDraft.uuid); + window.history.replaceState({}, '', url.toString()); + } + }; + + initializeDraft(); + }, [draftIdFromUrl, boardDetails, angle, createDraft, setLitUpHoldsMap]); + + // Auto-save draft when data changes + const autoSaveDraft = useCallback(async () => { + if (!currentDraft) return; + + const frames = generateFramesString(); + + try { + await updateDraft(currentDraft.uuid, { + name: climbName || '', + description: description || '', + frames, + litUpHoldsMap, + isDraft, + angle, + }); + } catch (error) { + console.error('Failed to auto-save draft:', error); + } + }, [currentDraft, generateFramesString, climbName, description, litUpHoldsMap, updateDraft, angle, isDraft]); + + // Debounced auto-save when data changes + useEffect(() => { + if (!currentDraft || isLoadingDraft) return; + + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + + autoSaveTimerRef.current = setTimeout(() => { + autoSaveDraft(); + }, 500); + + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + }; + }, [litUpHoldsMap, climbName, description, isDraft, currentDraft, isLoadingDraft, autoSaveDraft]); + // Send frames to board whenever litUpHoldsMap changes and we're connected useEffect(() => { if (isConnected) { @@ -151,6 +241,15 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN angle, }); + // Delete the local draft after successful save + if (currentDraft) { + try { + await deleteDraft(currentDraft.uuid); + } catch (error) { + console.error('Failed to delete draft after save:', error); + } + } + track('Climb Created', { boardLayout: boardDetails.layout_name || '', isDraft: isDraft, @@ -175,7 +274,7 @@ export default function CreateClimbForm({ boardDetails, angle, forkFrames, forkN } finally { setIsSaving(false); } - }, [generateFramesString, saveClimb, boardDetails, climbName, description, isDraft, angle, totalHolds, router]); + }, [generateFramesString, saveClimb, boardDetails, climbName, description, isDraft, angle, totalHolds, router, currentDraft, deleteDraft]); const handlePublish = useCallback(async () => { if (!isValid || !climbName.trim()) { diff --git a/packages/web/app/components/create-climb/use-create-climb.ts b/packages/web/app/components/create-climb/use-create-climb.ts index 1c9ac01b..10b8b572 100644 --- a/packages/web/app/components/create-climb/use-create-climb.ts +++ b/packages/web/app/components/create-climb/use-create-climb.ts @@ -134,6 +134,7 @@ export function useCreateClimb(boardName: BoardName, options?: UseCreateClimbOpt return { litUpHoldsMap, + setLitUpHoldsMap, handleHoldClick, generateFramesString, startingCount, diff --git a/packages/web/app/components/drafts/drafts-context.tsx b/packages/web/app/components/drafts/drafts-context.tsx new file mode 100644 index 00000000..d4010b43 --- /dev/null +++ b/packages/web/app/components/drafts/drafts-context.tsx @@ -0,0 +1,133 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; +import { + DraftClimb, + getAllDraftClimbs, + createDraftClimb, + updateDraftClimb, + deleteDraftClimb, + getDraftClimbsCount, +} from '@/app/lib/draft-climbs-db'; +import { BoardName } from '@/app/lib/types'; +import { LitUpHoldsMap } from '../board-renderer/types'; + +interface DraftsContextType { + drafts: DraftClimb[]; + draftsCount: number; + isLoading: boolean; + createDraft: ( + boardName: BoardName, + layoutId: number, + sizeId: number, + setIds: number[], + angle: number, + ) => Promise; + updateDraft: ( + uuid: string, + updates: { + name?: string; + description?: string; + frames?: string; + litUpHoldsMap?: LitUpHoldsMap; + isDraft?: boolean; + angle?: number; + }, + ) => Promise; + deleteDraft: (uuid: string) => Promise; + refreshDrafts: () => Promise; +} + +const DraftsContext = createContext(undefined); + +export const DraftsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [drafts, setDrafts] = useState([]); + const [draftsCount, setDraftsCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + const loadDrafts = useCallback(async () => { + try { + const [loadedDrafts, count] = await Promise.all([getAllDraftClimbs(), getDraftClimbsCount()]); + setDrafts(loadedDrafts); + setDraftsCount(count); + } catch (error) { + console.error('Failed to load drafts:', error); + } finally { + setIsLoading(false); + } + }, []); + + // Load drafts on mount + useEffect(() => { + loadDrafts(); + }, [loadDrafts]); + + const createDraft = useCallback( + async ( + boardName: BoardName, + layoutId: number, + sizeId: number, + setIds: number[], + angle: number, + ): Promise => { + const newDraft = await createDraftClimb(boardName, layoutId, sizeId, setIds, angle); + setDrafts((prev) => [newDraft, ...prev]); + setDraftsCount((prev) => prev + 1); + return newDraft; + }, + [], + ); + + const updateDraft = useCallback( + async ( + uuid: string, + updates: { + name?: string; + description?: string; + frames?: string; + litUpHoldsMap?: LitUpHoldsMap; + isDraft?: boolean; + angle?: number; + }, + ): Promise => { + await updateDraftClimb(uuid, updates); + setDrafts((prev) => + prev.map((draft) => + draft.uuid === uuid ? { ...draft, ...updates, updatedAt: Date.now() } : draft, + ), + ); + }, + [], + ); + + const deleteDraftHandler = useCallback(async (uuid: string): Promise => { + await deleteDraftClimb(uuid); + setDrafts((prev) => prev.filter((draft) => draft.uuid !== uuid)); + setDraftsCount((prev) => prev - 1); + }, []); + + const value = useMemo( + () => ({ + drafts, + draftsCount, + isLoading, + createDraft, + updateDraft, + deleteDraft: deleteDraftHandler, + refreshDrafts: loadDrafts, + }), + [drafts, draftsCount, isLoading, createDraft, updateDraft, deleteDraftHandler, loadDrafts], + ); + + return {children}; +}; + +export const useDrafts = (): DraftsContextType => { + const context = useContext(DraftsContext); + if (!context) { + throw new Error('useDrafts must be used within a DraftsProvider'); + } + return context; +}; + +export type { DraftsContextType, DraftClimb }; diff --git a/packages/web/app/components/drafts/drafts-drawer.tsx b/packages/web/app/components/drafts/drafts-drawer.tsx new file mode 100644 index 00000000..f712aa54 --- /dev/null +++ b/packages/web/app/components/drafts/drafts-drawer.tsx @@ -0,0 +1,137 @@ +'use client'; + +import React from 'react'; +import { Drawer, List, Button, Empty, Typography, Popconfirm, Space, Tag } from 'antd'; +import { DeleteOutlined, EditOutlined } from '@ant-design/icons'; +import { useRouter } from 'next/navigation'; +import { useDrafts, DraftClimb } from './drafts-context'; +import { generateLayoutSlug, generateSizeSlug, generateSetSlug } from '@/app/lib/url-utils'; +import styles from './drafts-drawer.module.css'; + +const { Text, Paragraph } = Typography; + +interface DraftsDrawerProps { + open: boolean; + onClose: () => void; +} + +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + +function DraftItem({ + draft, + onEdit, + onDelete, +}: { + draft: DraftClimb; + onEdit: (draft: DraftClimb) => void; + onDelete: (uuid: string) => void; +}) { + const holdCount = Object.keys(draft.litUpHoldsMap).length; + const boardNameCapitalized = draft.boardName.charAt(0).toUpperCase() + draft.boardName.slice(1); + + return ( + } + onClick={() => onEdit(draft)} + > + Resume + , + onDelete(draft.uuid)} + okText="Delete" + cancelText="Cancel" + okButtonProps={{ danger: true }} + > + , - onDelete(draft.uuid)} - okText="Delete" - cancelText="Cancel" - okButtonProps={{ danger: true }} - > - + onDelete(draft.uuid)} + okText="Delete" + cancelText="Cancel" + okButtonProps={{ danger: true }} + > + - onDelete(draft.uuid)} - okText="Delete" - cancelText="Cancel" - okButtonProps={{ danger: true }} - > -