Files
app/components/ThoughtGalaxy.tsx
Albert f0284ef813 feat: Improve UI layout and navigation
- Increase logo size (48x48 desktop, 56x56 mobile) for better visibility
- Add logo as favicon
- Add logo to mobile header
- Move user menu to navigation bars (sidebar on desktop, bottom bar on mobile)
- Fix desktop chat layout - container structure prevents voice controls cutoff
- Fix mobile bottom bar - use icon-only ActionIcons instead of truncated text buttons
- Hide Create Node/New Conversation buttons on mobile to save header space
- Make fixed header and voice controls work properly with containers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 14:43:11 +00:00

242 lines
6.8 KiB
TypeScript

'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 } from '@mantine/core';
import * as THREE from 'three';
// Define the shape of nodes and links from API
interface NodeData {
id: string;
title: 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, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeData) => void }) {
const [hovered, setHovered] = useState(false);
const [clicked, setClicked] = useState(false);
return (
<mesh
position={node.coords_3d}
onClick={(e) => {
e.stopPropagation();
onNodeClick(node);
setClicked(!clicked);
}}
onPointerOver={(e) => {
e.stopPropagation();
setHovered(true);
}}
onPointerOut={() => setHovered(false)}
>
<sphereGeometry args={[0.1, 32, 32]} />
<meshStandardMaterial
color={hovered ? '#90c0ff' : '#e9ecef'}
emissive={hovered ? '#90c0ff' : '#e9ecef'}
emissiveIntensity={hovered ? 0.5 : 0.1}
/>
{/* Show title on hover or click */}
{(hovered || clicked) && (
<Text
position={[0, 0.2, 0]}
fontSize={0.1}
color="white"
anchorX="center"
anchorY="middle"
>
{node.title}
</Text>
)}
</mesh>
);
}
// 2. The Main Scene Component
export function ThoughtGalaxy() {
const [nodes, setNodes] = useState<NodeData[]>([]);
const [links, setLinks] = useState<LinkData[]>([]);
const cameraControlsRef = useRef<CameraControls>(null);
const hasFitCamera = useRef(false);
// Fetch data from API on mount and poll for updates
useEffect(() => {
async function fetchData() {
try {
const response = await fetch('/api/galaxy', {
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);
// If calculating, poll again in 2 seconds
setTimeout(fetchData, 2000);
return;
}
setNodes(data.nodes || []);
setLinks(data.links || []);
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();
}, []);
// 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) {
// Try to fit after a short delay to ensure Canvas is ready
const timer = setTimeout(() => {
fitCameraToNodes();
}, 100);
return () => clearTimeout(timer);
}
}, [nodes]);
// 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) => {
if (cameraControlsRef.current) {
// Smoothly move to look at the clicked node
cameraControlsRef.current.moveTo(
node.coords_3d[0],
node.coords_3d[1],
node.coords_3d[2],
true // Animate
);
}
};
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 (
<Stack align="center" justify="center" style={{ height: '100vh', width: '100vw' }}>
<MantineText size="lg" c="dimmed">
Create at least 3 nodes to visualize your thought galaxy
</MantineText>
<MantineText size="sm" c="dimmed">
Nodes with content will automatically generate embeddings and 3D coordinates
</MantineText>
</Stack>
);
}
return (
<Canvas
camera={{ position: [0, 5, 10], fov: 60 }}
style={{ width: '100%', height: '100%' }}
gl={{ preserveDrawingBuffer: true }}
onCreated={(state) => {
console.log('[ThoughtGalaxy] Canvas created successfully');
// Try to fit camera now that scene is ready
if (!hasFitCamera.current && nodes.length > 0) {
setTimeout(() => fitCameraToNodes(), 50);
}
}}
>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} intensity={1} />
<CameraControls ref={cameraControlsRef} />
<Suspense fallback={null}>
<group>
{/* Render all nodes */}
{nodes.map((node) => (
<Node key={node.id} node={node} onNodeClick={handleNodeClick} />
))}
{/* Render all links */}
{linkLines.map((line, i) => (
<Line
key={i}
points={[line.start, line.end]}
color="#495057" // gray
lineWidth={1}
/>
))}
</group>
</Suspense>
</Canvas>
);
}