Initial commit

This commit is contained in:
2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions
+514
View File
@@ -0,0 +1,514 @@
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 { 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<Editor | null>(null);
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
// State
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
});
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isEditorReady, setIsEditorReady] = useState(false);
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(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
useEffect(() => {
if (!isEditorReady) {
logger.debug('single-player-page', '⏳ Waiting for editor to be ready');
return;
}
if (!user) {
logger.debug('single-player-page', '⏳ Waiting for user data');
return;
}
if (!editorRef.current) {
logger.debug('single-player-page', '⏳ Waiting for editor ref');
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);
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node
if (context.node) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
tldraw_snapshot: context.node.tldraw_snapshot,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.tldraw_snapshot,
user.user_db_name,
newStore,
setLoadingState
);
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 auto-save
newStore.listen(() => {
if (snapshotServiceRef.current && context.node) {
logger.debug('single-player-page', '💾 Auto-saving changes');
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Auto-save failed', error);
});
}
});
// 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();
}, [isEditorReady, user, context.node, editorRef.current]);
// Handle initial node placement
useEffect(() => {
const placeInitialNode = async () => {
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
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,
tldraw_snapshot: currentNode.tldraw_snapshot,
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?.id, context.history, store]);
// 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 || user.user_type !== 'admin') {
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) {
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--color-background)'
}}>
<CircularProgress />
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
zIndex: 1000,
}}>
<CircularProgress />
</div>
)}
{/* Error snackbar */}
<Snackbar
open={loadingState.status === 'error'}
autoHideDuration={6000}
onClose={() => setLoadingState({ status: 'ready', error: '' })}
>
<Alert severity="error" onClose={() => setLoadingState({ status: 'ready', error: '' })}>
{loadingState.error}
</Alert>
</Snackbar>
<Tldraw
user={tldrawUser}
store={store}
tools={singlePlayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
components={uiComponents}
overrides={uiOverrides}
embeds={singlePlayerEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
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');
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);
}
}}
/>
</div>
);
}
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// 1. Always fetch fresh data
const dbName = UserNeoDBService.getNodeDatabaseName(node);
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: 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,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
};
};