feat: Add delete functionality for user-authored nodes

- Add DELETE /api/nodes/[id] endpoint for deleting nodes
- Verify user authentication and ownership before deletion
- Delete from ATproto (source of truth) first, then SurrealDB cache
- Add delete button in ThoughtGalaxy component for user's own nodes
- Add confirmation modal before deletion
- Fix Modal z-index to appear above node detail panel (zIndex: 1001)
- Fix RecordId encoding issue (strip angle brackets ⟨⟩ from IDs)
- Remove deleted node and associated links from local state
- Add comprehensive Magnitude tests for delete functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-10 01:37:06 +00:00
parent a4739bddc1
commit 63c955c848
3 changed files with 402 additions and 10 deletions

View File

@@ -7,7 +7,9 @@ import {
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 { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button, Modal } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import * as THREE from 'three';
@@ -96,6 +98,9 @@ export function ThoughtGalaxy() {
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);
@@ -104,6 +109,28 @@ export function ThoughtGalaxy() {
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() {
@@ -290,6 +317,51 @@ export function ThoughtGalaxy() {
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
@@ -360,15 +432,30 @@ export function ThoughtGalaxy() {
<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>
<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"
@@ -394,6 +481,43 @@ export function ThoughtGalaxy() {
</Box>
)}
{/* Delete confirmation modal */}
<Modal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
title="Delete Node"
centered
zIndex={1001}
>
<Stack gap="md">
<MantineText>
Are you sure you want to delete this node? This will:
</MantineText>
<Stack gap="xs" ml="md">
<MantineText size="sm"> Remove the post from Bluesky</MantineText>
<MantineText size="sm"> Delete the node from your galaxy</MantineText>
<MantineText size="sm" fw={600} c="red">This action cannot be undone.</MantineText>
</Stack>
<Group justify="flex-end" gap="sm">
<Button
variant="subtle"
onClick={() => setDeleteConfirmOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
color="red"
onClick={handleDeleteNode}
loading={isDeleting}
leftSection={<IconTrash size={16} />}
>
Delete Permanently
</Button>
</Group>
</Stack>
</Modal>
<Canvas
camera={{ position: [0, 5, 10], fov: 60 }}
style={{ width: '100%', height: '100%' }}