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:
2025-11-09 18:26:39 +00:00
parent 4b3da74f79
commit 9aa9035d78
14 changed files with 453 additions and 102 deletions

View File

@@ -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' && (

View File

@@ -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>
</>
);
}

View File

@@ -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">