import { useEffect, useRef, useState } from 'react'; import { useNavigate, useLocation } from 'react-router'; import { Tldraw, Editor, useTldrawUser, DEFAULT_SUPPORT_VIDEO_TYPES, DEFAULT_SUPPORTED_IMAGE_TYPES, TLStore, TLStoreWithStatus } from '@tldraw/tldraw'; import { useTLDraw } from '../../contexts/TLDrawContext'; import { useUser } from '../../contexts/UserContext'; // Tldraw services import { localStoreService } from '../../services/tldraw/localStoreService'; import { PresentationService } from '../../services/tldraw/presentationService'; import { UserNeoDBService } from '../../services/graph/userNeoDBService'; import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService'; import { NavigationSnapshotService } from '../../services/tldraw/snapshotService'; // Tldraw utils import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides'; import { customAssets } from '../../utils/tldraw/assets'; import { singlePlayerTools } from '../../utils/tldraw/tools'; import { allShapeUtils } from '../../utils/tldraw/shapes'; import { allBindingUtils } from '../../utils/tldraw/bindings'; import { singlePlayerEmbeds } from '../../utils/tldraw/embeds'; import { customSchema } from '../../utils/tldraw/schemas'; // Navigation import { useNavigationStore } from '../../stores/navigationStore'; // Layout import { HEADER_HEIGHT } from '../../pages/Layout'; // Styles import '../../utils/tldraw/tldraw.css'; // App debug import { logger } from '../../debugConfig'; import { CircularProgress, Alert, Snackbar } from '@mui/material'; import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles'; import { NodeData } from '../../types/graph-shape'; import { NavigationNode } from '../../types/navigation'; interface LoadingState { status: 'ready' | 'loading' | 'error'; error: string; } export default function SinglePlayerPage() { // Context hooks with initialization states const { profile: user, loading: userLoading } = useUser(); const { tldrawPreferences, initializePreferences, presentationMode, setTldrawPreferences } = useTLDraw(); const routerNavigate = useNavigate(); const location = useLocation(); // Navigation store const { context } = useNavigationStore(); // Refs const editorRef = useRef(null); const snapshotServiceRef = useRef(null); // State const [loadingState, setLoadingState] = useState({ status: 'ready', error: '' }); const [isInitialLoad, setIsInitialLoad] = useState(true); const [isEditorReady, setIsEditorReady] = useState(false); const [store, setStore] = useState(undefined); // TLDraw user preferences const tldrawUser = useTldrawUser({ userPreferences: { id: user?.id ?? '', name: user?.display_name, color: tldrawPreferences?.color, locale: tldrawPreferences?.locale, colorScheme: tldrawPreferences?.colorScheme, animationSpeed: tldrawPreferences?.animationSpeed, isSnapMode: tldrawPreferences?.isSnapMode }, setUserPreferences: setTldrawPreferences }); // Initialize store โ€” runs as soon as user is ready, no editor needed for store creation useEffect(() => { if (!user) { logger.debug('single-player-page', 'โณ Waiting for user data'); return; } logger.info('single-player-page', '๐Ÿ”„ Starting store initialization', { isEditorReady, hasUser: !!user, userType: user.user_type, username: user.username }); const initializeStoreAndSnapshot = async () => { try { setLoadingState({ status: 'loading', error: '' }); // 1. Create store logger.debug('single-player-page', '๐Ÿ”„ Creating TLStore'); const newStore = localStoreService.getStore({ schema: customSchema, shapeUtils: allShapeUtils, bindingUtils: allBindingUtils }); logger.debug('single-player-page', 'โœ… TLStore created'); // 2. Initialize snapshot service const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined); snapshotServiceRef.current = snapshotService; logger.debug('single-player-page', 'โœจ Initialized NavigationSnapshotService'); // 3. Load initial snapshot if we have a node if (context.node) { const nodeStoragePath = getNodeStoragePath(context.node); if (nodeStoragePath) { logger.debug('single-player-page', '๐Ÿ“ฅ Loading snapshot from database', { dbName: user.user_db_name, node: context.node, node_storage_path: nodeStoragePath, user_type: user.user_type, username: user.username }); await NavigationSnapshotService.loadNodeSnapshotFromDatabase( nodeStoragePath, user.user_db_name, newStore, setLoadingState, undefined, // sharedStore editorRef.current || undefined // editor ); logger.debug('single-player-page', 'โœ… Snapshot loaded from database'); } else { logger.debug('single-player-page', 'โš ๏ธ No node_storage_path found in node, skipping snapshot load', { node: context.node }); } } else { logger.debug('single-player-page', 'โš ๏ธ No node in context, skipping snapshot load'); } // 4. Set up auto-save with debouncing (only after initial load is complete) let autoSaveTimeout: ReturnType | null = null; let isAutoSaving = false; newStore.listen(() => { if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) { // Skip if already saving if (isAutoSaving) { logger.debug('single-player-page', 'โš ๏ธ Skipping auto-save - already saving'); return; } // Clear existing timeout if (autoSaveTimeout) { clearTimeout(autoSaveTimeout); } // Debounce auto-save to prevent excessive saves autoSaveTimeout = setTimeout(async () => { if (isAutoSaving) return; // Double-check isAutoSaving = true; try { logger.debug('single-player-page', '๐Ÿ’พ Auto-saving changes (debounced)'); await snapshotServiceRef.current?.forceSaveCurrentNode(); } catch (error) { logger.error('single-player-page', 'โŒ Auto-save failed', error); } finally { isAutoSaving = false; } }, 2000); // Increased to 2 seconds debounce } else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) { logger.debug('single-player-page', 'โš ๏ธ Skipping auto-save - no current node path set yet'); } }); // 5. Update store state setStore(newStore); setLoadingState({ status: 'ready', error: '' }); logger.info('single-player-page', 'โœ… Store initialization complete'); // 6. Handle cleanup return () => { logger.debug('single-player-page', '๐Ÿงน Starting cleanup'); if (snapshotServiceRef.current) { snapshotServiceRef.current.forceSaveCurrentNode().catch(error => { logger.error('single-player-page', 'โŒ Final save failed', error); }); snapshotServiceRef.current.clearCurrentNode(); snapshotServiceRef.current = null; } newStore.dispose(); setStore(undefined); logger.debug('single-player-page', '๐Ÿงน Cleanup complete'); }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store'; logger.error('single-player-page', 'โŒ Store initialization failed', error); setLoadingState({ status: 'error', error: errorMessage }); return undefined; } }; initializeStoreAndSnapshot(); }, [user, context.node]); // Handle initial node placement useEffect(() => { const placeInitialNode = async () => { if (!context.node || !editorRef.current || !store || !isInitialLoad) { logger.debug('single-player-page', 'โš ๏ธ Skipping placeInitialNode - missing dependencies', { hasNode: !!context.node, hasEditor: !!editorRef.current, hasStore: !!store, isInitialLoad }); return; } // Debug: Log the actual node structure logger.debug('single-player-page', '๐Ÿ” Node structure for placeInitialNode', { node: context.node, nodeKeys: Object.keys(context.node), hasId: !!context.node.id, hasStoragePath: !!context.node.node_storage_path, hasData: !!context.node.data, dataKeys: context.node.data ? Object.keys(context.node.data) : null }); // Validate that the node has required properties const nodeStoragePath = getNodeStoragePath(context.node); if (!context.node.id || !nodeStoragePath) { logger.error('single-player-page', 'โŒ Node missing required properties', { nodeId: context.node.id, hasStoragePath: !!nodeStoragePath, node: context.node }); setLoadingState({ status: 'error', error: 'Node is missing required information' }); return; } try { setLoadingState({ status: 'loading', error: '' }); // Center the node const nodeData = await loadNodeData(context.node); await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData); setIsInitialLoad(false); setLoadingState({ status: 'ready', error: '' }); } catch (error) { logger.error('single-player-page', 'โŒ Failed to place initial node', error); setLoadingState({ status: 'error', error: error instanceof Error ? error.message : 'Failed to place initial node' }); } }; placeInitialNode(); }, [context.node, store, isInitialLoad]); // Handle navigation changes useEffect(() => { const handleNodeChange = async () => { if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) { return; } // We can safely assert these types because we've checked for null above const editor = editorRef.current as Editor; const snapshotService = snapshotServiceRef.current; const currentNode = context.node; try { setLoadingState({ status: 'loading', error: '' }); logger.debug('single-player-page', '๐Ÿ”„ Loading node data', { nodeId: currentNode.id, node_storage_path: currentNode.node_storage_path, isInitialLoad }); // Get the previous node from navigation history const previousNode = context.history.currentIndex > 0 ? context.history.nodes[context.history.currentIndex - 1] : null; // Handle navigation in snapshot service await snapshotService.handleNavigationStart(previousNode, currentNode); // Center the node on canvas const nodeData = await loadNodeData(currentNode); await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData); setLoadingState({ status: 'ready', error: '' }); } catch (error) { logger.error('single-player-page', 'โŒ Failed to load node data', error); setLoadingState({ status: 'error', error: error instanceof Error ? error.message : 'Failed to load node data' }); } }; handleNodeChange(); }, [context.node, context.history, store, isInitialLoad]); // Initialize preferences when user is available useEffect(() => { if (user?.id && !tldrawPreferences) { logger.debug('single-player-page', '๐Ÿ”„ Initializing preferences for user', { userId: user.id }); initializePreferences(user.id); } }, [user?.id, tldrawPreferences, initializePreferences]); // Redirect if no user or incorrect role useEffect(() => { if (!user || !['admin', 'email_teacher', 'school_admin', 'teacher'].includes(user.user_type || '')) { logger.info('single-player-page', '๐Ÿšช Redirecting to home - no user or incorrect role', { hasUser: !!user, userType: user?.user_type }); routerNavigate('/', { replace: true }); } }, [user, routerNavigate]); // Handle presentation mode useEffect(() => { if (presentationMode && editorRef.current) { logger.info('presentation', '๐Ÿ”„ Presentation mode changed', { presentationMode, editorExists: !!editorRef.current }); const editor = editorRef.current; const presentationService = new PresentationService(editor); const cleanup = presentationService.startPresentationMode(); return () => { logger.info('presentation', '๐Ÿงน Cleaning up presentation mode'); presentationService.stopPresentationMode(); cleanup(); }; } }, [presentationMode]); // Handle shared content useEffect(() => { const handleSharedContent = async () => { if (!editorRef.current || !location.state) { return; } const editor = editorRef.current; const { sharedFile, sharedContent } = location.state as { sharedFile?: File; sharedContent?: { title?: string; text?: string; url?: string; }; }; if (sharedFile) { logger.info('single-player-page', '๐Ÿ“ค Processing shared file', { name: sharedFile.name, type: sharedFile.type }); try { // Handle different file types if (sharedFile.type.startsWith('image/')) { const imageUrl = URL.createObjectURL(sharedFile); await editor.createShape({ type: 'image', props: { url: imageUrl, w: 320, h: 240, name: sharedFile.name } }); URL.revokeObjectURL(imageUrl); } else if (sharedFile.type === 'application/pdf') { // Handle PDF (you might want to implement PDF handling) logger.info('single-player-page', '๐Ÿ“„ PDF handling not implemented yet'); } else if (sharedFile.type === 'text/plain') { const text = await sharedFile.text(); editor.createShape({ type: 'text', props: { text } }); } } catch (error) { logger.error('single-player-page', 'โŒ Error processing shared file', { error }); } } if (sharedContent) { logger.info('single-player-page', '๐Ÿ“ค Processing shared content', { sharedContent }); const { title, text, url } = sharedContent; let contentText = ''; if (title) { contentText += `${title}\n`; } if (text) { contentText += `${text}\n`; } if (url) { contentText += url; } if (contentText) { editor.createShape({ type: 'text', props: { text: contentText } }); } } }; handleSharedContent(); }, [location.state]); // Modify the render logic to use presentationMode const uiOverrides = getUiOverrides(presentationMode); const uiComponents = getUiComponents(presentationMode); // Show loading state if user context is still loading if (userLoading || !user) { return (
); } return (
{/* Loading overlay - show when loading or contexts not initialized */} {(loadingState.status === 'loading' || !store) && (
)} {/* Error snackbar */} setLoadingState({ status: 'ready', error: '' })} > setLoadingState({ status: 'ready', error: '' })}> {loadingState.error} {store && []} onMount={(editor) => { logger.info('single-player-page', '๐ŸŽจ Starting Tldraw mount'); try { if (!editor) { logger.error('single-player-page', 'โŒ Editor is null in onMount'); return; } editorRef.current = editor; logger.debug('single-player-page', 'โœ… Editor ref set'); // Update snapshot service with editor reference if (snapshotServiceRef.current) { snapshotServiceRef.current.setEditor(editor); } setIsEditorReady(true); logger.info('single-player-page', 'โœ… Tldraw mounted successfully', { editorId: editor.store.id, presentationMode, isEditorReady: true }); } catch (error) { logger.error('single-player-page', 'โŒ Error in onMount', error); } }} />}
); } // Helper function to safely extract node_storage_path from different node structures const getNodeStoragePath = (node: NavigationNode): string | null => { // Try direct access first if (node.node_storage_path) { return node.node_storage_path; } // Try nested under data if (node.data?.node_storage_path) { return node.data.node_storage_path; } // Try other possible locations if (node.data?.storage_path && typeof node.data.storage_path === 'string') { return node.data.storage_path; } return null; }; const loadNodeData = async (node: NavigationNode): Promise => { // Validate the node parameter if (!node) { throw new Error('Node parameter is required'); } if (!node.id) { throw new Error('Node must have an ID'); } const nodeStoragePath = getNodeStoragePath(node); if (!nodeStoragePath) { throw new Error(`Node ${node.id} is missing node_storage_path`); } logger.debug('single-player-page', '๐Ÿ”„ Loading node data', { nodeId: node.id, nodeType: node.type, nodeLabel: node.label, nodeStoragePath: nodeStoragePath }); try { // 1. Always fetch fresh data // Create a temporary node object with the correct structure for the service const normalizedNode = { ...node, node_storage_path: nodeStoragePath }; const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode); const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName); if (!fetchedData?.node_data) { throw new Error('Failed to fetch node data'); } // 2. Process the data into the correct shape const theme = getThemeFromLabel(node.type); return { ...fetchedData.node_data, title: String(fetchedData.node_data.title || node.label || ''), w: 500, h: 350, state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null }, headerColor: theme.headerColor, backgroundColor: theme.backgroundColor, isLocked: false, __primarylabel__: node.type, uuid_string: node.id, node_storage_path: nodeStoragePath }; } catch (error) { logger.error('single-player-page', 'โŒ Error in loadNodeData', { nodeId: node.id, nodeType: node.type, error: error instanceof Error ? error.message : String(error) }); throw error; } };