Files
app/components/ThoughtGalaxy.tsx
Albert d656b06113 feat: Make galaxy viewable without login requirement
Implemented public galaxy viewing feature that allows unauthenticated
users to view public thought galaxies via the ?user={did} parameter,
while maintaining privacy controls for node-level visibility.

Changes:
- Updated /api/galaxy/route.ts to support public access:
  * Accept ?user={did} query parameter for viewing specific user's galaxy
  * Show all nodes (including private) for authenticated user viewing own galaxy
  * Filter to only public nodes when viewing someone else's galaxy
  * Return empty state with helpful message when not authenticated
  * Filter links to only show connections between visible nodes

- Added is_public field to database schema:
  * Updated db/schema.surql with DEFAULT true (public by default)
  * Created migration script scripts/add-is-public-field.ts
  * Aligns with ATproto's public-by-default philosophy

- Enhanced ThoughtGalaxy component:
  * Support viewing galaxies via ?user={did} parameter
  * Display user info banner when viewing public galaxy
  * Show appropriate empty state messages based on context
  * Refetch data when user parameter changes

- Created comprehensive Magnitude tests:
  * Test public galaxy viewing without authentication
  * Verify private nodes are hidden from public view
  * Test own galaxy access requires authentication
  * Validate invalid user DID handling
  * Test user info display and navigation between galaxies

- Documented implementation plan in plans/10-public-galaxy-viewing.md

This implements the "public by default" model while allowing future
node-level privacy controls. All canonical data remains on the user's
ATproto PDS, with SurrealDB serving as a high-performance cache.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 00:36:16 +00:00

442 lines
14 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 } from '@mantine/core';
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 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 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 });
};
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>
<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>
</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>
)}
<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>
</>
);
}