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 { useAuth } from '../../contexts/AuthContext'; import { useUser } from '../../contexts/UserContext'; // Tldraw services import { localStoreService } from '../../services/tldraw/localStoreService'; import { PresentationService } from '../../services/tldraw/presentationService'; 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'; interface LoadingState { status: 'ready' | 'loading' | 'error'; error: string; } export default function SinglePlayerPage() { // Context hooks with initialization states const { profile: user, loading: userLoading } = useUser(); const { accessToken } = useAuth(); const { context, setAuthInfo, switchContext } = useNavigationStore(); const { tldrawPreferences, initializePreferences, presentationMode, setTldrawPreferences } = useTLDraw(); const routerNavigate = useNavigate(); const location = useLocation(); // Refs const editorRef = useRef(null); const snapshotServiceRef = useRef(null); // State const [loadingState, setLoadingState] = useState({ status: 'ready', error: '' }); const storeRef = useRef(null); const [storeReady, setStoreReady] = useState(false); // 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', { 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); if (accessToken) snapshotService.setAccessToken(accessToken); snapshotServiceRef.current = snapshotService; logger.debug('single-player-page', 'โœจ Initialized NavigationSnapshotService'); // 3. Load initial snapshot if we have a node const storagePath = context.node?.node_storage_path; if (storagePath) { logger.debug('single-player-page', '๐Ÿ“ฅ Loading snapshot from database', { node_storage_path: storagePath, user_type: user.user_type, }); await NavigationSnapshotService.loadNodeSnapshotFromDatabase( storagePath, accessToken || '', newStore, setLoadingState, undefined, editorRef.current || undefined ); snapshotService.setCurrentNodePath(storagePath); logger.debug('single-player-page', 'โœ… Snapshot loaded from database'); } else { logger.debug('single-player-page', 'โš ๏ธ No node in context, skipping snapshot load'); } // 4. Set up debounced auto-save let autoSaveTimeout: ReturnType | null = null; let isAutoSaving = false; newStore.listen(() => { if (!snapshotServiceRef.current?.getCurrentNodePath()) return; if (isAutoSaving) return; if (autoSaveTimeout) clearTimeout(autoSaveTimeout); autoSaveTimeout = setTimeout(async () => { if (isAutoSaving) return; 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); }); // 5. Update store state storeRef.current = newStore; setStoreReady(true); 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; } storeRef.current?.dispose(); storeRef.current = null; setStoreReady(false); 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 navigation changes โ€” save previous snapshot, load next one. // No automatic node shape placement: the canvas shows only persisted state. useEffect(() => { const handleNodeChange = async () => { if (!context.node?.id || !snapshotServiceRef.current || !storeRef.current) return; const snapshotService = snapshotServiceRef.current; const currentNode = context.node; try { setLoadingState({ status: 'loading', error: '' }); logger.debug('single-player-page', '๐Ÿ”„ Handling navigation to node', { nodeId: currentNode.id, node_storage_path: currentNode.node_storage_path, }); const previousNode = context.history.currentIndex > 0 ? context.history.nodes[context.history.currentIndex - 1] : null; await snapshotService.handleNavigationStart(previousNode, currentNode); setLoadingState({ status: 'ready', error: '' }); } catch (error) { logger.error('single-player-page', 'โŒ Failed to load node snapshot', error); setLoadingState({ status: 'error', error: error instanceof Error ? error.message : 'Failed to load node data' }); } }; handleNodeChange(); }, [context.node, context.history, storeReady]); // Inject auth and trigger initial context when token is ready useEffect(() => { if (user?.id && accessToken) { setAuthInfo(accessToken, user.id); if (!context.node) { switchContext({ main: 'profile', base: 'profile' }, null, null); } } }, [user?.id, accessToken]); // 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' || !storeReady) && (
)} {/* Error snackbar */} setLoadingState({ status: 'ready', error: '' })} > setLoadingState({ status: 'ready', error: '' })}> {loadingState.error} {storeReady && storeRef.current && []} 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); if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken); } logger.info('single-player-page', 'โœ… Tldraw mounted successfully', { editorId: editor.store.id, presentationMode, }); } catch (error) { logger.error('single-player-page', 'โŒ Error in onMount', error); } }} />}
); }