'use client'; import { Canvas } from '@react-three/fiber'; import { CameraControls, Line, Text, } from '@react-three/drei'; import { Suspense, useEffect, useRef, useState } from 'react'; import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button, Modal } from '@mantine/core'; import { IconTrash } from '@tabler/icons-react'; import { notifications } from '@mantine/notifications'; import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import * as THREE from 'three'; // Define the shape of nodes and links from API interface NodeData { id: string; title: string; body?: string; user_did: string; atp_uri: string; coords_3d: [number, number, number]; } interface LinkData { in: string; // from node id out: string; // to node id } // 1. The 3D Node Component function Node({ node, isFocused, onNodeClick, isDark }: { node: NodeData; isFocused: boolean; onNodeClick: (node: NodeData) => void; isDark: boolean; }) { const [hovered, setHovered] = useState(false); const isExpanded = isFocused || hovered; const scale = isFocused ? 2.5 : 1; // Theme-aware colors const nodeColor = isDark ? '#e9ecef' : '#495057'; const focusColor = '#4dabf7'; const hoverColor = '#90c0ff'; return ( { e.stopPropagation(); onNodeClick(node); }} onPointerOver={(e) => { e.stopPropagation(); setHovered(true); }} onPointerOut={() => setHovered(false)} > {/* Show title on hover or focus */} {isExpanded && ( {node.title} )} ); } // 2. The Main Scene Component export function ThoughtGalaxy() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const colorScheme = useComputedColorScheme('light'); const isDark = colorScheme === 'dark'; const [nodes, setNodes] = useState([]); const [links, setLinks] = useState([]); const [selectedNode, setSelectedNode] = useState(null); const [emptyMessage, setEmptyMessage] = useState(null); const [currentUserDid, setCurrentUserDid] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const cameraControlsRef = useRef(null); const hasFitCamera = useRef(false); const hasFocusedNode = useRef(null); // Get query params const selectedNodeId = searchParams.get('node'); const targetUserDid = searchParams.get('user'); // For viewing someone else's galaxy // Fetch current user's profile to get their DID useEffect(() => { async function fetchCurrentUser() { try { const response = await fetch('/api/user/profile', { credentials: 'include', }); if (response.ok) { const data = await response.json(); setCurrentUserDid(data.did); } } catch (error) { console.error('[ThoughtGalaxy] Error fetching current user:', error); } } // Only fetch current user if we're viewing our own galaxy if (!targetUserDid) { fetchCurrentUser(); } }, [targetUserDid]); // Fetch data from API on mount and poll for updates useEffect(() => { async function fetchData() { try { // Build URL with optional user parameter const url = targetUserDid ? `/api/galaxy?user=${encodeURIComponent(targetUserDid)}` : '/api/galaxy'; const response = await fetch(url, { credentials: 'include', // Include cookies for authentication }); if (!response.ok) { console.error('[ThoughtGalaxy] Failed to fetch galaxy data:', response.statusText); return; } const data = await response.json(); if (data.message) { console.log('[ThoughtGalaxy]', data.message); setEmptyMessage(data.message); // If calculating, poll again in 2 seconds if (data.message.includes('calculating')) { setTimeout(fetchData, 2000); } return; } setNodes(data.nodes || []); setLinks(data.links || []); setEmptyMessage(null); console.log(`[ThoughtGalaxy] Loaded ${data.nodes?.length || 0} nodes and ${data.links?.length || 0} links`); } catch (error) { console.error('[ThoughtGalaxy] Error fetching data:', error); } } fetchData(); }, [targetUserDid]); // Function to fit camera to all nodes const fitCameraToNodes = () => { if (!cameraControlsRef.current || nodes.length === 0) { console.log('[ThoughtGalaxy] Cannot fit camera:', { hasRef: !!cameraControlsRef.current, nodesLength: nodes.length, }); return; } console.log('[ThoughtGalaxy] Fitting camera to', nodes.length, 'nodes...'); // Create a THREE.Box3 from node positions const box = new THREE.Box3(); nodes.forEach((node) => { box.expandByPoint(new THREE.Vector3( node.coords_3d[0], node.coords_3d[1], node.coords_3d[2] )); }); console.log('[ThoughtGalaxy] Bounding box:', { min: box.min, max: box.max, size: box.getSize(new THREE.Vector3()), }); // Use CameraControls' built-in fitToBox method try { cameraControlsRef.current.fitToBox( box, false, // Don't animate on initial load { paddingLeft: 0.5, paddingRight: 0.5, paddingTop: 0.5, paddingBottom: 0.5 } ); console.log('[ThoughtGalaxy] ✓ Camera fitted to bounds'); hasFitCamera.current = true; } catch (error) { console.error('[ThoughtGalaxy] Error fitting camera:', error); } }; // Fit camera when nodes change and we haven't fitted yet useEffect(() => { if (!hasFitCamera.current && nodes.length > 0 && !selectedNodeId) { // Only auto-fit if we're not focusing on a specific node // Try to fit after a short delay to ensure Canvas is ready const timer = setTimeout(() => { fitCameraToNodes(); }, 100); return () => clearTimeout(timer); } }, [nodes, selectedNodeId]); // Auto-focus on specific node if selectedNodeId is provided via query params useEffect(() => { if (selectedNodeId && nodes.length > 0) { const focusNode = nodes.find((n) => n.id === selectedNodeId); if (focusNode) { console.log('[ThoughtGalaxy] Focusing on node:', selectedNodeId); // Always update selected node when selectedNodeId changes (don't wait for camera ref) setSelectedNode(focusNode); // Move camera if ref is available and we haven't focused this specific node yet if (cameraControlsRef.current && (!hasFocusedNode.current || hasFocusedNode.current !== selectedNodeId)) { cameraControlsRef.current.setLookAt( focusNode.coords_3d[0], focusNode.coords_3d[1], focusNode.coords_3d[2] + 2, // Position camera 2 units in front focusNode.coords_3d[0], focusNode.coords_3d[1], focusNode.coords_3d[2], hasFocusedNode.current ? true : false // Animate if not initial load ); hasFocusedNode.current = selectedNodeId; } } else { // Node ID in URL doesn't exist in data, clear it setSelectedNode(null); } } else if (!selectedNodeId && selectedNode) { // Query param was cleared but we still have a selected node in state, clear it setSelectedNode(null); hasFocusedNode.current = null; } }, [selectedNodeId, nodes, selectedNode]); // Map links to node positions const linkLines = links .map((link) => { const startNode = nodes.find((n) => n.id === link.in); const endNode = nodes.find((n) => n.id === link.out); if (startNode && endNode) { return { start: startNode.coords_3d, end: endNode.coords_3d, }; } return null; }) .filter(Boolean) as { start: [number, number, number]; end: [number, number, number] }[]; // Camera animation on node click const handleNodeClick = (node: NodeData) => { console.log('[ThoughtGalaxy] Node clicked:', node.id); // Set selected node immediately for responsive UI setSelectedNode(node); // Update URL with query param (this won't cause remount) const params = new URLSearchParams(searchParams); params.set('node', node.id); router.replace(`${pathname}?${params.toString()}`, { scroll: false }); // Animate camera to node if (cameraControlsRef.current) { // Clear the focused node ref to ensure camera animates hasFocusedNode.current = null; cameraControlsRef.current.setLookAt( node.coords_3d[0], node.coords_3d[1], node.coords_3d[2] + 2, node.coords_3d[0], node.coords_3d[1], node.coords_3d[2], true // Animate ); } }; // Handle closing the modal const handleCloseModal = () => { console.log('[ThoughtGalaxy] Closing modal'); setSelectedNode(null); // Remove node query param from URL const params = new URLSearchParams(searchParams); params.delete('node'); const newSearch = params.toString(); router.replace(`${pathname}${newSearch ? `?${newSearch}` : ''}`, { scroll: false }); }; // Handle deleting a node const handleDeleteNode = async () => { if (!selectedNode) return; setIsDeleting(true); setDeleteConfirmOpen(false); try { // Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩) const cleanId = String(selectedNode.id).replace(/[⟨⟩]/g, ''); const response = await fetch(`/api/nodes/${cleanId}`, { method: 'DELETE', credentials: 'include', }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to delete node'); } notifications.show({ title: 'Node deleted', message: 'Node has been deleted from Bluesky and your galaxy', color: 'green', }); // Remove the node from local state setNodes((prevNodes) => prevNodes.filter((n) => n.id !== selectedNode.id)); setLinks((prevLinks) => prevLinks.filter((l) => l.in !== selectedNode.id && l.out !== selectedNode.id)); // Close the modal handleCloseModal(); } catch (error) { console.error('[ThoughtGalaxy] Delete error:', error); notifications.show({ title: 'Delete failed', message: error instanceof Error ? error.message : 'Failed to delete node', color: 'red', }); } finally { setIsDeleting(false); } }; console.log('[ThoughtGalaxy] Rendering with', nodes.length, 'nodes and', linkLines.length, 'link lines'); // Show message if no nodes are ready yet if (nodes.length === 0) { return ( {emptyMessage || 'Create at least 3 nodes to visualize your thought galaxy'} {!emptyMessage && ( Nodes with content will automatically generate embeddings and 3D coordinates )} {targetUserDid && ( Viewing galaxy for user: {targetUserDid} )} ); } return ( <> {/* User info banner when viewing someone else's galaxy */} {targetUserDid && ( Public Galaxy Viewing {nodes.length} public {nodes.length === 1 ? 'node' : 'nodes'} {targetUserDid} )} {/* Floating content overlay for selected node */} {selectedNode && ( {selectedNode.title} View on Bluesky {/* Show delete button only for user's own nodes */} {currentUserDid && selectedNode.user_did === currentUserDid && ( )} {selectedNode.body && ( {selectedNode.body} )} )} {/* Delete confirmation modal */} setDeleteConfirmOpen(false)} title="Delete Node" centered zIndex={1001} > Are you sure you want to delete this node? This will: • Remove the post from Bluesky • Delete the node from your galaxy This action cannot be undone. { console.log('[ThoughtGalaxy] Canvas created successfully'); // Try to fit camera now that scene is ready if (!hasFitCamera.current && nodes.length > 0) { setTimeout(() => fitCameraToNodes(), 50); } }} > {/* Theme-aware lighting */} {/* Render all nodes */} {nodes.map((node) => ( ))} {/* Render all links - theme-aware colors */} {linkLines.map((line, i) => ( ))} ); }