/ src / features / dreamnode-creator / DreamNodeCreator3D.tsx
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          >
350351          </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  }