Files
app/components/ThoughtGalaxy.tsx
Albert a520814771 feat: Implement node deletion with shared modal and fix SurrealDB RecordId handling
Implements complete node deletion functionality for both galaxy view and debug panel:

**Core Changes:**
- Created shared DeleteNodeModal component used by both ThoughtGalaxy and UserMenu
- Modal provides consistent UX with proper confirmation messaging
- Deletion follows write-through cache pattern: ATproto first, then SurrealDB

**SurrealDB RecordId Fixes:**
- Fixed SELECT query to use type::thing($table, $recordId) for UUID-based RecordIds
- Fixed DELETE query to use type::thing() instead of db.delete() to handle dashes in UUIDs
- Without type::thing(), SurrealDB interprets dashes as subtraction operators

**Testing & Documentation:**
- Added comprehensive Magnitude tests for delete functionality (galaxy view and debug panel)
- Updated CLAUDE.md with complete testing workflow documentation
- Added pre-commit checklist requiring database verification and test execution
- Documented PlaywrightMCP manual testing process before Magnitude test writing

**Database Setup:**
- Configured docker-compose.yml to use environment variables for credentials
- Updated namespace/database to match .env configuration (ponderants/main)

**File Changes:**
- app/api/nodes/[id]/route.ts: Fixed RecordId query patterns (SELECT and DELETE)
- components/DeleteNodeModal.tsx: New shared modal component
- components/ThoughtGalaxy.tsx: Uses shared DeleteNodeModal
- components/UserMenu.tsx: Replaced browser confirm() with shared DeleteNodeModal
- tests/magnitude/03-delete-node.mag.ts: Added debug panel delete test
- AGENTS.md: Added testing workflow and pre-commit checklist documentation
- docker-compose.yml: Environment variable configuration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 13:25:01 +00:00

539 lines
17 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, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { DeleteNodeModal } from './DeleteNodeModal';
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 (
<mesh
position={node.coords_3d}
scale={scale}
onClick={(e) => {
e.stopPropagation();
onNodeClick(node);
}}
onPointerOver={(e) => {
e.stopPropagation();
setHovered(true);
}}
onPointerOut={() => setHovered(false)}
>
<sphereGeometry args={[0.1, 32, 32]} />
<meshStandardMaterial
color={isFocused ? focusColor : (hovered ? hoverColor : nodeColor)}
emissive={isFocused ? focusColor : (hovered ? hoverColor : nodeColor)}
emissiveIntensity={isFocused ? 0.8 : (hovered ? 0.5 : 0.1)}
/>
{/* Show title on hover or focus */}
{isExpanded && (
<Text
position={[0, 0.3 / scale, 0]}
fontSize={0.1 / scale}
color={isDark ? 'white' : 'black'}
anchorX="center"
anchorY="middle"
>
{node.title}
</Text>
)}
</mesh>
);
}
// 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<NodeData[]>([]);
const [links, setLinks] = useState<LinkData[]>([]);
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
const [currentUserDid, setCurrentUserDid] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const cameraControlsRef = useRef<CameraControls>(null);
const hasFitCamera = useRef(false);
const hasFocusedNode = useRef<string | null>(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 (
<Stack align="center" justify="center" style={{ height: '100vh', width: '100vw' }}>
<MantineText size="lg" c="dimmed">
{emptyMessage || 'Create at least 3 nodes to visualize your thought galaxy'}
</MantineText>
{!emptyMessage && (
<MantineText size="sm" c="dimmed">
Nodes with content will automatically generate embeddings and 3D coordinates
</MantineText>
)}
{targetUserDid && (
<MantineText size="sm" c="dimmed" mt="xs">
Viewing galaxy for user: {targetUserDid}
</MantineText>
)}
</Stack>
);
}
return (
<>
{/* User info banner when viewing someone else's galaxy */}
{targetUserDid && (
<Box
style={{
position: 'absolute',
top: '10px',
right: '10px',
zIndex: 999,
maxWidth: '300px',
}}
>
<Paper p="sm" radius="md" withBorder shadow="md">
<MantineText size="sm" fw={600}>
Public Galaxy
</MantineText>
<MantineText size="xs" c="dimmed">
Viewing {nodes.length} public {nodes.length === 1 ? 'node' : 'nodes'}
</MantineText>
<MantineText size="xs" c="dimmed" style={{ wordBreak: 'break-all' }}>
{targetUserDid}
</MantineText>
</Paper>
</Box>
)}
{/* Floating content overlay for selected node */}
{selectedNode && (
<Box
style={{
position: 'absolute',
top: '10px',
left: '10px',
right: '10px',
zIndex: 1000,
maxWidth: '600px',
margin: '0 auto',
maxHeight: 'calc(100vh - 100px)', // Leave room for top/bottom padding
}}
>
<Paper p="md" radius="lg" withBorder shadow="xl" style={{ maxHeight: '100%', display: 'flex', flexDirection: 'column' }}>
<Group justify="space-between" align="flex-start" mb="xs" style={{ flexShrink: 0 }}>
<Box style={{ flex: 1, minWidth: 0 }}>
<Title order={2} style={{ margin: 0, marginBottom: '0.25rem' }}>
{selectedNode.title}
</Title>
<Group gap="sm" mt="xs">
<Anchor
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
target="_blank"
rel="noopener noreferrer"
size="sm"
c="dimmed"
>
View on Bluesky
</Anchor>
{/* Show delete button only for user's own nodes */}
{currentUserDid && selectedNode.user_did === currentUserDid && (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => setDeleteConfirmOpen(true)}
loading={isDeleting}
>
Delete
</Button>
)}
</Group>
</Box>
<CloseButton
size="lg"
onClick={handleCloseModal}
aria-label="Close node details"
style={{ flexShrink: 0 }}
/>
</Group>
{selectedNode.body && (
<Box style={{ overflowY: 'auto', flex: 1, marginTop: '0.5rem' }}>
<MantineText
size="md"
style={{
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
}}
>
{selectedNode.body}
</MantineText>
</Box>
)}
</Paper>
</Box>
)}
{/* Delete confirmation modal */}
<DeleteNodeModal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleDeleteNode}
nodeTitle={selectedNode?.title || null}
isDeleting={isDeleting}
/>
<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);
}
}}
>
{/* Theme-aware lighting */}
<ambientLight intensity={isDark ? 0.3 : 0.5} />
<pointLight position={[10, 10, 10]} intensity={isDark ? 0.8 : 1} />
<CameraControls ref={cameraControlsRef} />
<Suspense fallback={null}>
<group>
{/* Render all nodes */}
{nodes.map((node) => (
<Node
key={node.id}
node={node}
isFocused={selectedNodeId === node.id || selectedNode?.id === node.id}
onNodeClick={handleNodeClick}
isDark={isDark}
/>
))}
{/* Render all links - theme-aware colors */}
{linkLines.map((line, i) => (
<Line
key={i}
points={[line.start, line.end]}
color={isDark ? '#495057' : '#adb5bd'}
lineWidth={1}
/>
))}
</group>
</Suspense>
</Canvas>
</>
);
}