fix: Correct OAuth localhost/127.0.0.1 config and fix grapheme counting for Bluesky posts
- Fixed OAuth client configuration to properly use localhost for client_id and 127.0.0.1 for redirect_uris per RFC 8252 and ATproto spec - Added proper grapheme counting using RichText API instead of character length - Fixed thread splitting to account for link suffix and thread indicators in grapheme limits - Added GOOGLE_EMBEDDING_DIMENSIONS env var to all env files - Added clear-nodes.ts utility script for database management - Added galaxy node detail page route 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -111,11 +111,8 @@ export function DesktopSidebar() {
|
||||
|
||||
<Divider my="md" color="#373A40" />
|
||||
|
||||
<NavLink
|
||||
label="Profile"
|
||||
leftSection={<Box style={{ width: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><UserMenu /></Box>}
|
||||
variant="filled"
|
||||
/>
|
||||
{/* User Menu - styled like other nav items */}
|
||||
<UserMenu showLabel={true} />
|
||||
|
||||
{/* Development state panel */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
|
||||
@@ -7,13 +7,17 @@ import {
|
||||
Text,
|
||||
} from '@react-three/drei';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { Stack, Text as MantineText } from '@mantine/core';
|
||||
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor } from '@mantine/core';
|
||||
import { useRouter, usePathname } 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];
|
||||
}
|
||||
|
||||
@@ -23,17 +27,27 @@ interface LinkData {
|
||||
}
|
||||
|
||||
// 1. The 3D Node Component
|
||||
function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeData) => void }) {
|
||||
function Node({
|
||||
node,
|
||||
isFocused,
|
||||
onNodeClick
|
||||
}: {
|
||||
node: NodeData;
|
||||
isFocused: boolean;
|
||||
onNodeClick: (node: NodeData) => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [clicked, setClicked] = useState(false);
|
||||
|
||||
const isExpanded = isFocused || hovered;
|
||||
const scale = isFocused ? 2.5 : 1;
|
||||
|
||||
return (
|
||||
<mesh
|
||||
position={node.coords_3d}
|
||||
scale={scale}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNodeClick(node);
|
||||
setClicked(!clicked);
|
||||
}}
|
||||
onPointerOver={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -43,15 +57,15 @@ function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeD
|
||||
>
|
||||
<sphereGeometry args={[0.1, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={hovered ? '#90c0ff' : '#e9ecef'}
|
||||
emissive={hovered ? '#90c0ff' : '#e9ecef'}
|
||||
emissiveIntensity={hovered ? 0.5 : 0.1}
|
||||
color={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
|
||||
emissive={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
|
||||
emissiveIntensity={isFocused ? 0.8 : (hovered ? 0.5 : 0.1)}
|
||||
/>
|
||||
{/* Show title on hover or click */}
|
||||
{(hovered || clicked) && (
|
||||
{/* Show title on hover or focus */}
|
||||
{isExpanded && (
|
||||
<Text
|
||||
position={[0, 0.2, 0]}
|
||||
fontSize={0.1}
|
||||
position={[0, 0.3 / scale, 0]}
|
||||
fontSize={0.1 / scale}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
@@ -64,11 +78,15 @@ function Node({ node, onNodeClick }: { node: NodeData; onNodeClick: (node: NodeD
|
||||
}
|
||||
|
||||
// 2. The Main Scene Component
|
||||
export function ThoughtGalaxy() {
|
||||
export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [nodes, setNodes] = useState<NodeData[]>([]);
|
||||
const [links, setLinks] = useState<LinkData[]>([]);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
||||
const cameraControlsRef = useRef<CameraControls>(null);
|
||||
const hasFitCamera = useRef(false);
|
||||
const hasFocusedNode = useRef<string | null>(null);
|
||||
|
||||
// Fetch data from API on mount and poll for updates
|
||||
useEffect(() => {
|
||||
@@ -148,14 +166,42 @@ export function ThoughtGalaxy() {
|
||||
|
||||
// Fit camera when nodes change and we haven't fitted yet
|
||||
useEffect(() => {
|
||||
if (!hasFitCamera.current && nodes.length > 0) {
|
||||
if (!hasFitCamera.current && nodes.length > 0 && !focusNodeId) {
|
||||
// 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]);
|
||||
}, [nodes, focusNodeId]);
|
||||
|
||||
// Auto-focus on specific node if focusNodeId is provided
|
||||
useEffect(() => {
|
||||
if (focusNodeId && nodes.length > 0) {
|
||||
const focusNode = nodes.find((n) => n.id === focusNodeId);
|
||||
if (focusNode) {
|
||||
console.log('[ThoughtGalaxy] Focusing on node:', focusNodeId);
|
||||
|
||||
// Always update selected node when focusNodeId 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 !== focusNodeId)) {
|
||||
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 = focusNodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [focusNodeId, nodes]);
|
||||
|
||||
// Map links to node positions
|
||||
const linkLines = links
|
||||
@@ -174,14 +220,30 @@ export function ThoughtGalaxy() {
|
||||
|
||||
// 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
|
||||
);
|
||||
const targetPath = `/galaxy/${encodeURIComponent(node.id)}`;
|
||||
|
||||
// Set selected node immediately for responsive UI
|
||||
setSelectedNode(node);
|
||||
|
||||
// Only navigate if we're not already on this node's page
|
||||
if (pathname !== targetPath) {
|
||||
// Clear the focused node ref to ensure camera animates on next render
|
||||
hasFocusedNode.current = null;
|
||||
// Use replace instead of push to avoid page reload issues
|
||||
router.replace(targetPath);
|
||||
} else {
|
||||
// Already on this page, just animate camera to node
|
||||
if (cameraControlsRef.current) {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,18 +264,73 @@ export function ThoughtGalaxy() {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{/* 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={() => setSelectedNode(null)}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||
<CameraControls ref={cameraControlsRef} />
|
||||
@@ -222,7 +339,12 @@ export function ThoughtGalaxy() {
|
||||
<group>
|
||||
{/* Render all nodes */}
|
||||
{nodes.map((node) => (
|
||||
<Node key={node.id} node={node} onNodeClick={handleNodeClick} />
|
||||
<Node
|
||||
key={node.id}
|
||||
node={node}
|
||||
isFocused={focusNodeId === node.id || selectedNode?.id === node.id}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render all links */}
|
||||
@@ -237,5 +359,6 @@ export function ThoughtGalaxy() {
|
||||
</group>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Menu, Avatar, UnstyledButton, Group, Text } from '@mantine/core';
|
||||
import { Menu, Avatar, NavLink, ActionIcon } from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface UserProfile {
|
||||
@@ -11,7 +11,7 @@ interface UserProfile {
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export function UserMenu() {
|
||||
export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -43,10 +43,30 @@ export function UserMenu() {
|
||||
};
|
||||
|
||||
if (loading || !profile) {
|
||||
return (
|
||||
<Avatar radius="xl" size="md" color="gray">
|
||||
?
|
||||
</Avatar>
|
||||
return showLabel ? (
|
||||
<NavLink
|
||||
label="Profile"
|
||||
leftSection={
|
||||
<Avatar radius="xl" size={20} color="gray">
|
||||
?
|
||||
</Avatar>
|
||||
}
|
||||
variant="filled"
|
||||
color="blue"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
fontWeight: 400,
|
||||
},
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<ActionIcon variant="subtle" color="gray" size={40} radius="md">
|
||||
<Avatar radius="xl" size={24} color="gray">
|
||||
?
|
||||
</Avatar>
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,29 +85,49 @@ export function UserMenu() {
|
||||
return (
|
||||
<Menu shadow="md" width={200} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group gap="xs">
|
||||
{showLabel ? (
|
||||
<NavLink
|
||||
label="Profile"
|
||||
leftSection={
|
||||
<Avatar
|
||||
src={profile.avatar}
|
||||
alt={displayText}
|
||||
radius="xl"
|
||||
size={20}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
}
|
||||
variant="filled"
|
||||
color="blue"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: '8px',
|
||||
fontWeight: 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ActionIcon variant="subtle" color="gray" size={40} radius="md">
|
||||
<Avatar
|
||||
src={profile.avatar}
|
||||
alt={displayText}
|
||||
radius="xl"
|
||||
size="md"
|
||||
style={{ cursor: 'pointer' }}
|
||||
size={24}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
<Text size="xs" fw={600}>
|
||||
{displayText}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{displayText}
|
||||
<br />
|
||||
<span style={{ color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
|
||||
@{profile.handle}
|
||||
</Text>
|
||||
</span>
|
||||
</Menu.Label>
|
||||
<Menu.Divider />
|
||||
<Menu.Item onClick={handleLogout} c="red">
|
||||
|
||||
Reference in New Issue
Block a user