Files
app/components/UserMenu.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

328 lines
9.8 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { Menu, Avatar, NavLink, ActionIcon, SegmentedControl, Text, Stack, ScrollArea, Code } from '@mantine/core';
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { DeleteNodeModal } from './DeleteNodeModal';
interface UserProfile {
did: string;
handle: string;
displayName: string | null;
avatar: string | null;
}
interface Node {
id: string;
title: string;
user_did: string;
}
export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
const router = useRouter();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [nodes, setNodes] = useState<Node[]>([]);
const [nodesLoading, setNodesLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
useEffect(() => {
// Fetch user profile on mount
fetch('/api/user/profile')
.then((res) => res.json())
.then((data) => {
if (!data.error) {
setProfile(data);
}
})
.catch((error) => {
console.error('Failed to fetch profile:', error);
})
.finally(() => {
setLoading(false);
});
}, []);
// Fetch user's nodes for debugging
const fetchNodes = async () => {
setNodesLoading(true);
try {
const response = await fetch('/api/nodes/debug', {
credentials: 'include',
});
const data = await response.json();
if (!data.error) {
setNodes(data.nodes || []);
}
} catch (error) {
console.error('Failed to fetch nodes:', error);
} finally {
setNodesLoading(false);
}
};
// Delete a node (debug) - Matches ThoughtGalaxy delete pattern
const handleDebugDelete = async () => {
if (!nodeToDelete) return;
setIsDeleting(true);
setDeleteConfirmOpen(false);
try {
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
const cleanId = String(nodeToDelete.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',
});
// Update local state to remove the deleted node
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeToDelete.id));
setNodeToDelete(null);
} catch (error) {
console.error('[UserMenu Debug] Delete error:', error);
notifications.show({
title: 'Delete failed',
message: error instanceof Error ? error.message : 'Failed to delete node',
color: 'red',
});
} finally {
setIsDeleting(false);
}
};
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
} catch (error) {
console.error('Logout failed:', error);
}
};
if (loading || !profile) {
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>
);
}
// Get display name or handle
const displayText = profile.displayName || profile.handle;
// Get initials for fallback
const initials = profile.displayName
? profile.displayName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
: profile.handle.slice(0, 2).toUpperCase();
return (
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
{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={24}
>
{initials}
</Avatar>
</ActionIcon>
)}
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
{displayText}
<br />
<span style={{ color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
@{profile.handle}
</span>
</Menu.Label>
<Menu.Divider />
{/* Theme Selection */}
<div style={{ padding: '8px 12px' }}>
<Text size="xs" fw={500} c="dimmed" mb={8}>
Theme
</Text>
<SegmentedControl
value={colorScheme}
onChange={(value) => setColorScheme(value as 'light' | 'dark' | 'auto')}
data={[
{
value: 'light',
label: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<IconSun size={16} />
</div>
),
},
{
value: 'dark',
label: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<IconMoon size={16} />
</div>
),
},
{
value: 'auto',
label: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<IconDeviceDesktop size={16} />
</div>
),
},
]}
fullWidth
size="xs"
/>
</div>
{/* Debug: Show all nodes */}
{process.env.NODE_ENV === 'development' && (
<>
<Menu.Divider />
<Menu.Label>Debug: SurrealDB Nodes</Menu.Label>
<div style={{ padding: '8px 12px' }}>
<Text size="xs" fw={500} c="dimmed" mb={8}>
<button
onClick={fetchNodes}
style={{
background: 'none',
border: 'none',
color: 'inherit',
textDecoration: 'underline',
cursor: 'pointer',
padding: 0,
}}
>
{nodesLoading ? 'Loading...' : `Fetch Nodes (${nodes.length})`}
</button>
</Text>
{nodes.length > 0 && (
<ScrollArea h={200}>
<Stack gap="xs">
{nodes.map((node) => (
<div key={node.id} style={{ fontSize: '0.7rem', marginBottom: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<Text size="xs" fw={600}>{node.title}</Text>
<button
onClick={() => {
setNodeToDelete(node);
setDeleteConfirmOpen(true);
}}
style={{
background: '#fa5252',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '2px 6px',
cursor: 'pointer',
fontSize: '0.65rem',
}}
disabled={isDeleting}
>
Delete
</button>
</div>
<Code block style={{ fontSize: '0.65rem' }}>{node.id}</Code>
</div>
))}
</Stack>
</ScrollArea>
)}
{nodes.length === 0 && !nodesLoading && (
<Text size="xs" c="red">
No nodes found in SurrealDB
</Text>
)}
</div>
</>
)}
<Menu.Divider />
<Menu.Item onClick={handleLogout} c="red">
Log out
</Menu.Item>
</Menu.Dropdown>
{/* Delete confirmation modal - shared with ThoughtGalaxy */}
<DeleteNodeModal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleDebugDelete}
nodeTitle={nodeToDelete?.title || null}
isDeleting={isDeleting}
/>
</Menu>
);
}