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>
This commit is contained in:
59
components/AppLayout.tsx
Normal file
59
components/AppLayout.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* AppLayout Component
|
||||
*
|
||||
* Wraps the application with:
|
||||
* - AppStateMachineProvider (state management)
|
||||
* - Mantine AppShell (responsive layout)
|
||||
* - Navigation (mobile bottom bar / desktop sidebar)
|
||||
*/
|
||||
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { AppStateMachineProvider } from './AppStateMachine';
|
||||
import { MobileBottomBar } from './Navigation/MobileBottomBar';
|
||||
import { MobileHeader } from './Navigation/MobileHeader';
|
||||
import { DesktopSidebar } from './Navigation/DesktopSidebar';
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
return (
|
||||
<AppStateMachineProvider>
|
||||
{/* Mobile Header - only on mobile */}
|
||||
{isMobile && <MobileHeader />}
|
||||
|
||||
<AppShell
|
||||
navbar={{
|
||||
width: isMobile ? 0 : 200,
|
||||
breakpoint: 'sm',
|
||||
}}
|
||||
padding={isMobile ? 0 : 'md'}
|
||||
style={{ height: '100vh' }}
|
||||
>
|
||||
{/* Desktop Sidebar - only on desktop */}
|
||||
{!isMobile && (
|
||||
<AppShell.Navbar>
|
||||
<DesktopSidebar />
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<AppShell.Main
|
||||
style={{
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
paddingTop: isMobile ? '64px' : '0', // Space for mobile header
|
||||
paddingBottom: isMobile ? '80px' : '0', // Space for mobile bottom bar
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Mobile Bottom Bar - only on mobile */}
|
||||
{isMobile && <MobileBottomBar />}
|
||||
</AppShell>
|
||||
</AppStateMachineProvider>
|
||||
);
|
||||
}
|
||||
108
components/AppStateMachine.tsx
Normal file
108
components/AppStateMachine.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* AppStateMachine Provider
|
||||
*
|
||||
* Wraps the application with the app-level state machine.
|
||||
* Provides state and send function to all child components via context.
|
||||
* Also handles responsive mode detection and route synchronization.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSelector } from '@xstate/react';
|
||||
import { createActor } from 'xstate';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { appMachine } from '@/lib/app-machine';
|
||||
import { AppMachineContext } from '@/hooks/useAppMachine';
|
||||
|
||||
// Create the actor singleton outside the component to persist state
|
||||
const appActor = createActor(appMachine);
|
||||
appActor.start();
|
||||
|
||||
export function AppStateMachineProvider({ children }: { children: React.ReactNode }) {
|
||||
const state = useSelector(appActor, (state) => state);
|
||||
const send = appActor.send;
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// Track if this is the initial mount
|
||||
const isInitializedRef = useRef(false);
|
||||
// Track the last path we navigated to, to prevent loops
|
||||
const lastNavigatedPathRef = useRef<string | null>(null);
|
||||
|
||||
// Detect mobile vs desktop
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
// Update mode in state machine
|
||||
useEffect(() => {
|
||||
send({ type: 'SET_MODE', mode: isMobile ? 'mobile' : 'desktop' });
|
||||
}, [isMobile, send]);
|
||||
|
||||
// Initialize state machine from URL on first mount ONLY
|
||||
useEffect(() => {
|
||||
if (isInitializedRef.current) return;
|
||||
|
||||
console.log('[App Provider] Initializing state from URL:', pathname);
|
||||
|
||||
// Determine which state the current path corresponds to
|
||||
let initialEvent: string | null = null;
|
||||
|
||||
if (pathname === '/chat') {
|
||||
initialEvent = 'NAVIGATE_TO_CONVO';
|
||||
} else if (pathname === '/edit') {
|
||||
initialEvent = 'NAVIGATE_TO_EDIT';
|
||||
} else if (pathname === '/galaxy') {
|
||||
initialEvent = 'NAVIGATE_TO_GALAXY';
|
||||
}
|
||||
|
||||
// Send the event to initialize state from URL
|
||||
if (initialEvent) {
|
||||
console.log('[App Provider] Setting initial state:', initialEvent);
|
||||
send({ type: initialEvent as any });
|
||||
}
|
||||
|
||||
// Mark as initialized AFTER sending the event
|
||||
isInitializedRef.current = true;
|
||||
}, [pathname, send]); // Remove 'state' from dependencies!
|
||||
|
||||
// State machine is source of truth: sync state → URL only
|
||||
// This effect ONLY runs when state changes, not when pathname changes
|
||||
useEffect(() => {
|
||||
// Don't navigate until initialized
|
||||
if (!isInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetPath: string | null = null;
|
||||
|
||||
if (state.matches('convo')) {
|
||||
targetPath = '/chat';
|
||||
} else if (state.matches('edit')) {
|
||||
targetPath = '/edit';
|
||||
} else if (state.matches('galaxy')) {
|
||||
targetPath = '/galaxy';
|
||||
}
|
||||
|
||||
// ONLY navigate if we have a target path and haven't already navigated to it
|
||||
if (targetPath && targetPath !== lastNavigatedPathRef.current) {
|
||||
console.log('[App Provider] State machine navigating to:', targetPath);
|
||||
lastNavigatedPathRef.current = targetPath;
|
||||
router.push(targetPath);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.value]); // ONLY depend on state.value, NOT pathname or router!
|
||||
|
||||
// Log state changes
|
||||
useEffect(() => {
|
||||
console.log('[App Provider] State:', state.value);
|
||||
console.log('[App Provider] Tags:', Array.from(state.tags));
|
||||
console.log('[App Provider] Context:', state.context);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<AppMachineContext.Provider value={appActor}>
|
||||
{children}
|
||||
</AppMachineContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useChat } from 'ai';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { Container, ScrollArea, Paper, Group, TextInput, Button, Stack, Text, Box } from '@mantine/core';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MicrophoneRecorder } from './MicrophoneRecorder';
|
||||
|
||||
export function ChatInterface() {
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
setInput,
|
||||
isLoading,
|
||||
} = useChat({
|
||||
api: '/api/chat',
|
||||
});
|
||||
sendMessage,
|
||||
status,
|
||||
} = useChat();
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
@@ -57,7 +53,12 @@ export function ChatInterface() {
|
||||
radius="md"
|
||||
bg={message.role === 'user' ? 'dark.6' : 'dark.7'}
|
||||
>
|
||||
<Text size="sm">{message.content}</Text>
|
||||
<Text size="sm">
|
||||
{/* Extract text from parts */}
|
||||
{('parts' in message && Array.isArray((message as any).parts))
|
||||
? (message as any).parts.find((p: any) => p.type === 'text')?.text || ''
|
||||
: (message as any).content || ''}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
@@ -65,16 +66,21 @@ export function ChatInterface() {
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input area */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || status === 'submitted' || status === 'streaming') return;
|
||||
sendMessage({ text: input });
|
||||
setInput('');
|
||||
}}>
|
||||
<Paper withBorder p="sm" radius="xl">
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
placeholder="Speak or type your thoughts..."
|
||||
style={{ flex: 1 }}
|
||||
variant="unstyled"
|
||||
disabled={isLoading}
|
||||
disabled={status === 'submitted' || status === 'streaming'}
|
||||
/>
|
||||
|
||||
{/* Microphone Recorder */}
|
||||
@@ -96,7 +102,7 @@ export function ChatInterface() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit" radius="xl" loading={isLoading}>
|
||||
<Button type="submit" radius="xl" loading={status === 'submitted' || status === 'streaming'}>
|
||||
Send
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
127
components/Navigation/DesktopSidebar.tsx
Normal file
127
components/Navigation/DesktopSidebar.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Desktop Sidebar Navigation
|
||||
*
|
||||
* Vertical sidebar navigation for desktop (≥ 768px).
|
||||
* Shows three navigation links: Convo, Edit, Galaxy
|
||||
* Highlights the active mode based on app state machine.
|
||||
*/
|
||||
|
||||
import { Stack, NavLink, Box, Text, Group, Image, Divider } from '@mantine/core';
|
||||
import { IconMessageCircle, IconEdit, IconUniverse } from '@tabler/icons-react';
|
||||
import { useSelector } from '@xstate/react';
|
||||
import { useAppMachine } from '@/hooks/useAppMachine';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
|
||||
export function DesktopSidebar() {
|
||||
const actor = useAppMachine();
|
||||
const state = useSelector(actor, (state) => state);
|
||||
const send = actor.send;
|
||||
|
||||
const handleNavigation = (target: 'convo' | 'edit' | 'galaxy') => {
|
||||
console.log('[Desktop Nav] Navigating to:', target);
|
||||
|
||||
if (target === 'convo') {
|
||||
send({ type: 'NAVIGATE_TO_CONVO' });
|
||||
} else if (target === 'edit') {
|
||||
send({ type: 'NAVIGATE_TO_EDIT' });
|
||||
} else if (target === 'galaxy') {
|
||||
send({ type: 'NAVIGATE_TO_GALAXY' });
|
||||
}
|
||||
};
|
||||
|
||||
const isConvo = state.matches('convo');
|
||||
const isEdit = state.matches('edit');
|
||||
const isGalaxy = state.matches('galaxy');
|
||||
|
||||
console.log('[Desktop Nav] Current state:', state.value, {
|
||||
isConvo,
|
||||
isEdit,
|
||||
isGalaxy,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRight: '1px solid #dee2e6',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group gap="sm" mb="md" align="center">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Ponderants logo"
|
||||
w={48}
|
||||
h={48}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fw={700} size="md" c="dimmed">
|
||||
Ponderants
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<NavLink
|
||||
label="Convo"
|
||||
leftSection={<IconMessageCircle size={20} />}
|
||||
active={isConvo}
|
||||
onClick={() => handleNavigation('convo')}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<NavLink
|
||||
label="Manual"
|
||||
leftSection={<IconEdit size={20} />}
|
||||
active={isEdit}
|
||||
onClick={() => handleNavigation('edit')}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<NavLink
|
||||
label="Galaxy"
|
||||
leftSection={<IconUniverse size={20} />}
|
||||
active={isGalaxy}
|
||||
onClick={() => handleNavigation('galaxy')}
|
||||
variant="filled"
|
||||
/>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
<Box style={{ padding: '0.5rem' }}>
|
||||
<UserMenu />
|
||||
</Box>
|
||||
|
||||
{/* Development state panel */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Box mt="xl" p="sm" style={{ border: '1px solid #495057', borderRadius: '4px' }}>
|
||||
<Text size="xs" fw={700} c="dimmed" mb="xs">
|
||||
DEV: App State
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
State: {JSON.stringify(state.value)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Tags: {Array.from(state.tags).join(', ')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Mode: {state.context.mode}
|
||||
</Text>
|
||||
{state.context.pendingNodeDraft && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Draft: {state.context.pendingNodeDraft.title || '(untitled)'}
|
||||
</Text>
|
||||
)}
|
||||
{state.context.currentNodeId && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Node: {state.context.currentNodeId}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
95
components/Navigation/MobileBottomBar.tsx
Normal file
95
components/Navigation/MobileBottomBar.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Mobile Bottom Bar Navigation
|
||||
*
|
||||
* Fixed bottom navigation for mobile devices (< 768px).
|
||||
* Shows three buttons: Convo, Edit, Galaxy
|
||||
* Highlights the active mode based on app state machine.
|
||||
*/
|
||||
|
||||
import { Group, Button, Paper, ActionIcon, Box } from '@mantine/core';
|
||||
import { IconMessageCircle, IconEdit, IconUniverse, IconUser } from '@tabler/icons-react';
|
||||
import { useSelector } from '@xstate/react';
|
||||
import { useAppMachine } from '@/hooks/useAppMachine';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
|
||||
export function MobileBottomBar() {
|
||||
const actor = useAppMachine();
|
||||
const state = useSelector(actor, (state) => state);
|
||||
const send = actor.send;
|
||||
|
||||
const handleNavigation = (target: 'convo' | 'edit' | 'galaxy') => {
|
||||
console.log('[Mobile Nav] Navigating to:', target);
|
||||
|
||||
if (target === 'convo') {
|
||||
send({ type: 'NAVIGATE_TO_CONVO' });
|
||||
} else if (target === 'edit') {
|
||||
send({ type: 'NAVIGATE_TO_EDIT' });
|
||||
} else if (target === 'galaxy') {
|
||||
send({ type: 'NAVIGATE_TO_GALAXY' });
|
||||
}
|
||||
};
|
||||
|
||||
const isConvo = state.matches('convo');
|
||||
const isEdit = state.matches('edit');
|
||||
const isGalaxy = state.matches('galaxy');
|
||||
|
||||
console.log('[Mobile Nav] Current state:', state.value, {
|
||||
isConvo,
|
||||
isEdit,
|
||||
isGalaxy,
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius={0}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
borderTop: '1px solid #dee2e6',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-around" grow>
|
||||
<ActionIcon
|
||||
variant={isConvo ? 'filled' : 'subtle'}
|
||||
color={isConvo ? 'blue' : 'gray'}
|
||||
onClick={() => handleNavigation('convo')}
|
||||
size={48}
|
||||
radius="md"
|
||||
>
|
||||
<IconMessageCircle size={24} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant={isEdit ? 'filled' : 'subtle'}
|
||||
color={isEdit ? 'blue' : 'gray'}
|
||||
onClick={() => handleNavigation('edit')}
|
||||
size={48}
|
||||
radius="md"
|
||||
>
|
||||
<IconEdit size={24} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant={isGalaxy ? 'filled' : 'subtle'}
|
||||
color={isGalaxy ? 'blue' : 'gray'}
|
||||
onClick={() => handleNavigation('galaxy')}
|
||||
size={48}
|
||||
radius="md"
|
||||
>
|
||||
<IconUniverse size={24} />
|
||||
</ActionIcon>
|
||||
|
||||
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<UserMenu />
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
40
components/Navigation/MobileHeader.tsx
Normal file
40
components/Navigation/MobileHeader.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Mobile Header
|
||||
*
|
||||
* Fixed header for mobile devices showing the Ponderants logo.
|
||||
*/
|
||||
|
||||
import { Group, Image, Text, Paper } from '@mantine/core';
|
||||
|
||||
export function MobileHeader() {
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius={0}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
borderBottom: '1px solid #dee2e6',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="center">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Ponderants logo"
|
||||
w={56}
|
||||
h={56}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fw={700} size="xl">
|
||||
Ponderants
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
Text,
|
||||
} from '@react-three/drei';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import Surreal from 'surrealdb';
|
||||
import { Stack, Text as MantineText } from '@mantine/core';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Define the shape of nodes and links from DB
|
||||
// Define the shape of nodes and links from API
|
||||
interface NodeData {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -67,42 +68,95 @@ 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 SurrealDB on mount
|
||||
// Fetch data from API on mount and poll for updates
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
// Client-side connection
|
||||
const db = new Surreal();
|
||||
await db.connect(process.env.NEXT_PUBLIC_SURREALDB_WSS_URL!);
|
||||
try {
|
||||
const response = await fetch('/api/galaxy', {
|
||||
credentials: 'include', // Include cookies for authentication
|
||||
});
|
||||
|
||||
// Get the token from the cookie
|
||||
const tokenCookie = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('ponderants-auth='));
|
||||
if (!response.ok) {
|
||||
console.error('[ThoughtGalaxy] Failed to fetch galaxy data:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tokenCookie) {
|
||||
console.error('[ThoughtGalaxy] No auth token found');
|
||||
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);
|
||||
}
|
||||
|
||||
const token = tokenCookie.split('=')[1];
|
||||
await db.authenticate(token);
|
||||
|
||||
// Fetch nodes that have coordinates
|
||||
const nodeResults = await db.query<[NodeData[]]>(
|
||||
'SELECT id, title, coords_3d FROM node WHERE coords_3d != NONE'
|
||||
);
|
||||
setNodes(nodeResults[0] || []);
|
||||
|
||||
// Fetch links
|
||||
const linkResults = await db.query<[LinkData[]]>('SELECT in, out FROM links_to');
|
||||
setLinks(linkResults[0] || []);
|
||||
|
||||
console.log(`[ThoughtGalaxy] Loaded ${nodeResults[0]?.length || 0} nodes and ${linkResults[0]?.length || 0} links`);
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -118,24 +172,48 @@ export function ThoughtGalaxy() {
|
||||
})
|
||||
.filter(Boolean) as { start: [number, number, number]; end: [number, number, number] }[];
|
||||
|
||||
// Camera animation
|
||||
// Camera animation on node click
|
||||
const handleNodeClick = (node: NodeData) => {
|
||||
if (cameraControlsRef.current) {
|
||||
cameraControlsRef.current.smoothTime = 0.8;
|
||||
cameraControlsRef.current.setLookAt(
|
||||
node.coords_3d[0] + 1,
|
||||
node.coords_3d[1] + 1,
|
||||
node.coords_3d[2] + 1,
|
||||
// Smoothly move to look at the clicked node
|
||||
cameraControlsRef.current.moveTo(
|
||||
node.coords_3d[0],
|
||||
node.coords_3d[1],
|
||||
node.coords_3d[2],
|
||||
true // Enable smooth transition
|
||||
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 }}>
|
||||
<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} />
|
||||
|
||||
Reference in New Issue
Block a user