DreamNodeCreator3D.tsx
1 import React, { useState, useRef, useCallback } from 'react'; 2 import { Html } from '@react-three/drei'; 3 import { useFrame } from '@react-three/fiber'; 4 import { dreamNodeStyles, getNodeColors, getGoldenGlow, getMediaContainerStyle, getMediaOverlayStyle } from '../dreamnode/styles/dreamNodeStyles'; 5 import { isValidDreamTalkMedia, DropZone, ValidationError, validateDreamNodeTitle, isTitleValid } from '../dreamnode'; 6 import { useInterBrainStore } from '../../core/store/interbrain-store'; 7 import { useOrchestrator } from '../../core/context/orchestrator-context'; 8 import { serviceManager } from '../../core/services/service-manager'; 9 import { UIService } from '../../core/services/ui-service'; 10 import type { DraftDreamNode } from './store/slice'; 11 12 const uiService = new UIService(); 13 14 /** 15 * DreamNodeCreator3D - Translucent in-space creation UI for DreamNodes 16 * 17 * This is a self-contained component that: 18 * - Renders when creation mode is active (checks store state internally) 19 * - Shows a translucent preview at the specified 3D position 20 * - Handles title input, type selection, and media upload 21 * - Calls GitDreamNodeService.create() on completion 22 * - Manages its own animation for the creation transition 23 */ 24 export default function DreamNodeCreator3D() { 25 const titleInputRef = useRef<globalThis.HTMLInputElement>(null); 26 const fileInputRef = useRef<globalThis.HTMLInputElement>(null); 27 28 // Store state and actions 29 const { 30 creationState, 31 updateDraft, 32 setValidationErrors, 33 completeCreation, 34 cancelCreation 35 } = useInterBrainStore(); 36 37 const { draft, validationErrors } = creationState; 38 39 // Orchestrator for position calculation 40 const orchestrator = useOrchestrator(); 41 42 // Local UI state 43 const [localTitle, setLocalTitle] = useState(draft?.title || ''); 44 const [isDragOver, setIsDragOver] = useState(false); 45 const [previewMedia, setPreviewMedia] = useState<string | null>(null); 46 const [isAnimating, setIsAnimating] = useState(false); 47 48 // Animation state 49 const [animatedPosition, setAnimatedPosition] = useState<[number, number, number]>( 50 draft?.position || [0, 0, -25] 51 ); 52 const [animatedUIOpacity, setAnimatedUIOpacity] = useState(1.0); 53 const animationStartTime = useRef<number | null>(null); 54 55 // Animation: move z toward -75, fade out UI 56 useFrame(() => { 57 if (!animationStartTime.current || !draft) return; 58 59 const progress = Math.min((Date.now() - animationStartTime.current) / 1000, 1); 60 const ease = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2; 61 62 setAnimatedPosition([draft.position[0], draft.position[1], draft.position[2] + (-75 - draft.position[2]) * ease]); 63 setAnimatedUIOpacity(1 - ease); 64 65 if (progress >= 1) animationStartTime.current = null; 66 }); 67 68 // Focus title input on type change 69 React.useEffect(() => { 70 if (draft && titleInputRef.current) titleInputRef.current.focus(); 71 }, [draft?.type]); 72 73 // Generate preview URL for pre-filled media with cleanup 74 React.useEffect(() => { 75 if (!draft?.dreamTalkFile || previewMedia) { 76 return; 77 } 78 const url = globalThis.URL.createObjectURL(draft.dreamTalkFile); 79 setPreviewMedia(url); 80 // Cleanup URL on unmount to prevent memory leaks 81 return () => { 82 globalThis.URL.revokeObjectURL(url); 83 }; 84 }, [draft?.dreamTalkFile, previewMedia]); 85 86 // Validation (must be before early return - rules of hooks) 87 const validateTitle = useCallback((title: string) => { 88 const errors = validateDreamNodeTitle(title); 89 setValidationErrors(errors); 90 return isTitleValid(errors); 91 }, [setValidationErrors]); 92 93 // Don't render if not in creation mode 94 if (!creationState.isCreating || !draft) { 95 return null; 96 } 97 98 const nodeColors = getNodeColors(draft.type); 99 const nodeSize = dreamNodeStyles.dimensions.nodeSizeThreeD; 100 const borderWidth = dreamNodeStyles.dimensions.borderWidth; 101 102 // Event handlers 103 const handleTitleChange = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => { 104 const title = e.target.value; 105 setLocalTitle(title); 106 updateDraft({ title }); 107 }; 108 109 const handleTypeChange = (type: 'dream' | 'dreamer') => { 110 updateDraft({ type }); 111 globalThis.setTimeout(() => titleInputRef.current?.focus(), 0); 112 }; 113 114 const handleDragOver = (e: React.DragEvent) => { 115 e.preventDefault(); 116 e.stopPropagation(); 117 setIsDragOver(true); 118 }; 119 120 const handleDragLeave = (e: React.DragEvent) => { 121 e.preventDefault(); 122 e.stopPropagation(); 123 setIsDragOver(false); 124 }; 125 126 const handleDrop = (e: React.DragEvent) => { 127 e.preventDefault(); 128 e.stopPropagation(); 129 setIsDragOver(false); 130 131 const file = e.dataTransfer.files[0]; 132 if (file && isValidDreamTalkMedia(file)) { 133 updateDraft({ dreamTalkFile: file }); 134 setPreviewMedia(globalThis.URL.createObjectURL(file)); 135 } 136 }; 137 138 const handleFileSelect = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => { 139 const file = e.target.files?.[0]; 140 if (file && isValidDreamTalkMedia(file)) { 141 updateDraft({ dreamTalkFile: file }); 142 setPreviewMedia(globalThis.URL.createObjectURL(file)); 143 } 144 }; 145 146 const handleCreate = async () => { 147 if (!validateTitle(localTitle)) return; 148 149 setIsAnimating(true); 150 animationStartTime.current = Date.now(); 151 152 // Wait for animation, then create 153 globalThis.setTimeout(async () => { 154 try { 155 let finalPosition = draft.position; 156 if (orchestrator) { 157 finalPosition = orchestrator.calculateForwardPositionOnSphere(); 158 } 159 160 const service = serviceManager.getActive(); 161 await service.create( 162 draft.title, 163 draft.type, 164 draft.dreamTalkFile, 165 finalPosition, 166 draft.additionalFiles 167 ); 168 169 globalThis.setTimeout(() => { 170 setIsAnimating(false); 171 completeCreation(); 172 }, 100); 173 } catch (error) { 174 console.error('DreamNodeCreator3D: Failed to create:', error); 175 uiService.showError(error instanceof Error ? error.message : 'Failed to create DreamNode'); 176 setIsAnimating(false); 177 } 178 }, 1000); 179 }; 180 181 const handleCancel = () => { 182 if (previewMedia) { 183 globalThis.URL.revokeObjectURL(previewMedia); 184 } 185 cancelCreation(); 186 }; 187 188 const handleKeyDown = (e: React.KeyboardEvent) => { 189 if (e.key === 'Enter' && !e.shiftKey) { 190 e.preventDefault(); 191 handleCreate(); 192 } 193 }; 194 195 const isCreateDisabled = !localTitle.trim() || !!validationErrors.title || isAnimating; 196 197 return ( 198 <group position={animatedPosition}> 199 <Html center transform sprite distanceFactor={10} style={{ pointerEvents: 'auto', userSelect: 'none' }}> 200 <div 201 onKeyDown={handleKeyDown} 202 data-ui-element="dreamnode-creator" 203 onMouseDown={(e) => e.stopPropagation()} 204 onMouseMove={(e) => e.stopPropagation()} 205 onMouseUp={(e) => e.stopPropagation()} 206 onClick={(e) => e.stopPropagation()} 207 > 208 {/* Main Circle */} 209 <div 210 style={{ 211 width: `${nodeSize}px`, 212 height: `${nodeSize}px`, 213 borderRadius: dreamNodeStyles.dimensions.borderRadius, 214 border: `${borderWidth}px solid ${nodeColors.border}`, 215 background: nodeColors.fill, 216 overflow: 'hidden', 217 position: 'relative', 218 transition: dreamNodeStyles.transitions.creation, 219 boxShadow: getGoldenGlow(15), 220 fontFamily: dreamNodeStyles.typography.fontFamily 221 }} 222 onDragOver={handleDragOver} 223 onDragLeave={handleDragLeave} 224 onDrop={handleDrop} 225 > 226 {/* Media Preview or Drop Zone */} 227 {previewMedia || draft.dreamTalkFile || draft.urlMetadata ? ( 228 <div style={getMediaContainerStyle()}> 229 {previewMedia && ( 230 <img src={previewMedia} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> 231 )} 232 {draft.urlMetadata && !previewMedia && ( 233 <UrlPreview urlMetadata={draft.urlMetadata} /> 234 )} 235 <div style={getMediaOverlayStyle()} /> 236 </div> 237 ) : ( 238 <DropZone 239 isDragOver={isDragOver} 240 opacity={animatedUIOpacity} 241 onClickBrowse={() => fileInputRef.current?.click()} 242 /> 243 )} 244 245 {/* Title Input */} 246 <input 247 ref={titleInputRef} 248 type="text" 249 value={localTitle} 250 onChange={handleTitleChange} 251 placeholder="Name" 252 autoFocus 253 style={{ 254 position: 'absolute', 255 top: '50%', 256 left: '50%', 257 transform: 'translate(-50%, -50%)', 258 width: `${Math.max(120, nodeSize * 0.7)}px`, 259 height: `${Math.max(32, nodeSize * 0.12)}px`, 260 padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(12, nodeSize * 0.03)}px`, 261 background: 'transparent', 262 border: 'none', 263 borderRadius: '4px', 264 color: dreamNodeStyles.colors.text.primary, 265 fontSize: `${Math.max(14, nodeSize * 0.08)}px`, 266 fontFamily: dreamNodeStyles.typography.fontFamily, 267 textAlign: 'center', 268 outline: 'none', 269 zIndex: 10, 270 pointerEvents: 'auto', 271 cursor: 'text' 272 }} 273 onClick={(e) => e.stopPropagation()} 274 /> 275 276 <input 277 ref={fileInputRef} 278 type="file" 279 accept="image/*,video/*,application/pdf,.pdf" 280 onChange={handleFileSelect} 281 style={{ display: 'none' }} 282 /> 283 </div> 284 285 {/* Validation Error */} 286 {validationErrors.title && ( 287 <ValidationError message={validationErrors.title} nodeSize={nodeSize} opacity={animatedUIOpacity} /> 288 )} 289 290 {/* Type Toggle */} 291 <TypeToggle 292 currentType={draft.type} 293 onChange={handleTypeChange} 294 nodeSize={nodeSize} 295 hasError={!!validationErrors.title} 296 opacity={animatedUIOpacity} 297 /> 298 299 {/* Action Buttons */} 300 <ActionButtons 301 onCancel={handleCancel} 302 onCreate={handleCreate} 303 isDisabled={isCreateDisabled} 304 isAnimating={isAnimating} 305 nodeSize={nodeSize} 306 hasError={!!validationErrors.title} 307 opacity={animatedUIOpacity} 308 nodeColors={nodeColors} 309 /> 310 </div> 311 </Html> 312 </group> 313 ); 314 } 315 316 // ============================================================================ 317 // SUB-COMPONENTS 318 // ============================================================================ 319 320 function UrlPreview({ urlMetadata }: { urlMetadata: DraftDreamNode['urlMetadata'] }) { 321 if (!urlMetadata) return null; 322 323 if (urlMetadata.type === 'youtube' && urlMetadata.videoId) { 324 return ( 325 <div style={{ width: '100%', height: '100%', position: 'relative' }}> 326 <img 327 src={`https://img.youtube.com/vi/${urlMetadata.videoId}/maxresdefault.jpg`} 328 alt="YouTube thumbnail" 329 style={{ width: '100%', height: '100%', objectFit: 'cover' }} 330 /> 331 <div 332 style={{ 333 position: 'absolute', 334 top: '50%', 335 left: '50%', 336 transform: 'translate(-50%, -50%)', 337 width: '40%', 338 height: '40%', 339 background: 'rgba(255, 0, 0, 0.8)', 340 borderRadius: '8px', 341 display: 'flex', 342 alignItems: 'center', 343 justifyContent: 'center', 344 color: 'white', 345 fontSize: '16px', 346 fontWeight: 'bold', 347 pointerEvents: 'none' 348 }} 349 > 350 ▶ 351 </div> 352 </div> 353 ); 354 } 355 356 return ( 357 <div 358 style={{ 359 width: '100%', 360 height: '100%', 361 display: 'flex', 362 alignItems: 'center', 363 justifyContent: 'center', 364 background: urlMetadata.type === 'website' 365 ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' 366 : 'rgba(0, 100, 200, 0.8)', 367 color: '#FFFFFF', 368 fontSize: '24px', 369 fontWeight: 'bold' 370 }} 371 > 372 {urlMetadata.type === 'website' ? '🔗' : 'URL'} 373 </div> 374 ); 375 } 376 377 function TypeToggle({ currentType, onChange, nodeSize, hasError, opacity }: { 378 currentType: 'dream' | 'dreamer'; 379 onChange: (type: 'dream' | 'dreamer') => void; 380 nodeSize: number; 381 hasError: boolean; 382 opacity: number; 383 }) { 384 const buttonStyle = (type: 'dream' | 'dreamer') => ({ 385 padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`, 386 border: `${dreamNodeStyles.dimensions.toggleBorderWidth}px solid ${getNodeColors(type).border}`, 387 background: currentType === type ? getNodeColors(type).border : 'transparent', 388 color: 'white', 389 fontSize: `${Math.max(14, nodeSize * 0.035)}px`, 390 fontFamily: dreamNodeStyles.typography.fontFamily, 391 cursor: 'pointer', 392 transition: 'background 0.2s ease', 393 flex: '1', 394 minWidth: '60px', 395 boxSizing: 'border-box' as const 396 }); 397 398 return ( 399 <div 400 style={{ 401 position: 'absolute', 402 top: `${nodeSize + (hasError ? 40 : 20)}px`, 403 left: '50%', 404 transform: 'translateX(-50%)', 405 display: 'flex', 406 opacity 407 }} 408 > 409 <button 410 onClick={(e) => { e.stopPropagation(); onChange('dream'); }} 411 onMouseDown={(e) => e.stopPropagation()} 412 style={{ 413 ...buttonStyle('dream'), 414 borderRadius: `${Math.max(4, nodeSize * 0.01)}px 0 0 ${Math.max(4, nodeSize * 0.01)}px`, 415 marginRight: '0.5px' 416 }} 417 > 418 Dream 419 </button> 420 <button 421 onClick={(e) => { e.stopPropagation(); onChange('dreamer'); }} 422 onMouseDown={(e) => e.stopPropagation()} 423 style={{ 424 ...buttonStyle('dreamer'), 425 borderRadius: `0 ${Math.max(4, nodeSize * 0.01)}px ${Math.max(4, nodeSize * 0.01)}px 0` 426 }} 427 > 428 Dreamer 429 </button> 430 </div> 431 ); 432 } 433 434 function ActionButtons({ onCancel, onCreate, isDisabled, isAnimating, nodeSize, hasError, opacity, nodeColors }: { 435 onCancel: () => void; 436 onCreate: () => void; 437 isDisabled: boolean; 438 isAnimating: boolean; 439 nodeSize: number; 440 hasError: boolean; 441 opacity: number; 442 nodeColors: ReturnType<typeof getNodeColors>; 443 }) { 444 const basePadding = `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`; 445 const fontSize = `${Math.max(14, nodeSize * 0.035)}px`; 446 const borderRadius = `${Math.max(4, nodeSize * 0.01)}px`; 447 448 return ( 449 <div 450 style={{ 451 position: 'absolute', 452 top: `${nodeSize + (hasError ? 120 : 100)}px`, 453 left: '50%', 454 transform: 'translateX(-50%)', 455 display: 'flex', 456 gap: '12px', 457 opacity 458 }} 459 > 460 <button 461 onClick={(e) => { e.stopPropagation(); onCancel(); }} 462 onMouseDown={(e) => e.stopPropagation()} 463 disabled={isAnimating} 464 style={{ 465 padding: basePadding, 466 border: '1px solid rgba(255,255,255,0.5)', 467 background: 'transparent', 468 color: isAnimating ? 'rgba(255,255,255,0.5)' : 'white', 469 fontSize, 470 fontFamily: dreamNodeStyles.typography.fontFamily, 471 borderRadius, 472 cursor: isAnimating ? 'not-allowed' : 'pointer', 473 transition: dreamNodeStyles.transitions.default 474 }} 475 > 476 Cancel 477 </button> 478 <button 479 onClick={(e) => { e.stopPropagation(); onCreate(); }} 480 onMouseDown={(e) => e.stopPropagation()} 481 disabled={isDisabled} 482 style={{ 483 padding: basePadding, 484 border: 'none', 485 background: isDisabled ? 'rgba(255,255,255,0.3)' : nodeColors.border, 486 color: isDisabled ? 'rgba(255,255,255,0.5)' : 'white', 487 fontSize, 488 fontFamily: dreamNodeStyles.typography.fontFamily, 489 borderRadius, 490 cursor: isDisabled ? 'not-allowed' : 'pointer', 491 transition: dreamNodeStyles.transitions.default 492 }} 493 > 494 {isAnimating ? 'Creating...' : 'Create'} 495 </button> 496 </div> 497 ); 498 }