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>
328 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|