navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table, setAuthInfo(token, userId) pattern, auto-creates default room per context on first use snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…), setAccessToken() instance method, static methods take accessToken not dbName AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken navigation types: NeoGraphNode updated for Supabase-backed tree structure transcriptionStore/Service: getSession() removed, accessToken via AuthContext LLMConfigModal: auth context wiring fixes GraphNavigator/GraphSidebar: updated nav components Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
226 lines
8.1 KiB
TypeScript
226 lines
8.1 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Box, IconButton, CircularProgress, Tooltip, Collapse,
|
|
} from '@mui/material';
|
|
import {
|
|
ChevronLeft, ChevronRight,
|
|
ExpandMore, ChevronRight as ChevronRightIcon,
|
|
Home as HomeIcon, CalendarToday, DateRange, Event,
|
|
} from '@mui/icons-material';
|
|
import { useNavigationStore } from '../../stores/navigationStore';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { NeoGraphNode } from '../../types/navigation';
|
|
import { logger } from '../../debugConfig';
|
|
|
|
interface TreeNode extends NeoGraphNode {
|
|
has_children?: boolean;
|
|
children?: TreeNode[];
|
|
}
|
|
|
|
const NODE_ICONS: Record<string, React.ElementType> = {
|
|
User: HomeIcon,
|
|
CalendarYear: CalendarToday,
|
|
CalendarMonth: DateRange,
|
|
CalendarWeek: DateRange,
|
|
CalendarDay: Event,
|
|
};
|
|
|
|
const SIDEBAR_WIDTH = 220;
|
|
|
|
interface TreeItemProps {
|
|
node: TreeNode;
|
|
depth: number;
|
|
onSelect: (node: TreeNode) => void;
|
|
onExpand: (node: TreeNode) => Promise<TreeNode[]>;
|
|
activeRoomId?: string;
|
|
}
|
|
|
|
function TreeItem({ node, depth, onSelect, onExpand, activeRoomId }: TreeItemProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [children, setChildren] = useState<TreeNode[]>(node.children || []);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const Icon = NODE_ICONS[node.node_type] || HomeIcon;
|
|
const canExpand = node.has_children !== false && node.node_type !== 'CalendarDay';
|
|
|
|
const handleToggle = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!expanded && children.length === 0 && canExpand) {
|
|
setLoading(true);
|
|
try {
|
|
const loaded = await onExpand(node);
|
|
setChildren(loaded);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
setExpanded(v => !v);
|
|
};
|
|
|
|
return (
|
|
<Box>
|
|
<Box
|
|
onClick={() => onSelect(node)}
|
|
sx={{
|
|
display: 'flex', alignItems: 'center',
|
|
pl: depth * 1.5 + 0.5, pr: 0.5, py: 0.4,
|
|
cursor: 'pointer', borderRadius: 1, mx: 0.5,
|
|
fontSize: '0.78rem', minHeight: 28,
|
|
bgcolor: 'transparent',
|
|
'&:hover': { bgcolor: 'action.hover' },
|
|
}}
|
|
>
|
|
<Box sx={{ width: 18, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
|
{canExpand && (
|
|
loading
|
|
? <CircularProgress size={10} />
|
|
: (
|
|
<IconButton
|
|
size="small" sx={{ p: 0, color: 'text.secondary' }}
|
|
onClick={handleToggle}
|
|
>
|
|
{expanded
|
|
? <ExpandMore sx={{ fontSize: 14 }} />
|
|
: <ChevronRightIcon sx={{ fontSize: 14 }} />}
|
|
</IconButton>
|
|
)
|
|
)}
|
|
</Box>
|
|
<Icon sx={{ fontSize: 14, mr: 0.75, flexShrink: 0, color: 'text.secondary' }} />
|
|
<Box sx={{
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
flexGrow: 1, color: 'text.primary',
|
|
}}>
|
|
{node.label}
|
|
</Box>
|
|
</Box>
|
|
{canExpand && (
|
|
<Collapse in={expanded} timeout="auto">
|
|
{children.map(child => (
|
|
<TreeItem
|
|
key={child.neo4j_node_id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
onSelect={onSelect}
|
|
onExpand={onExpand}
|
|
activeRoomId={activeRoomId}
|
|
/>
|
|
))}
|
|
</Collapse>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
interface GraphSidebarProps {
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
export function GraphSidebar({ open, onToggle }: GraphSidebarProps) {
|
|
const { accessToken } = useAuth();
|
|
const { navigateToNeoNode, context } = useNavigationStore();
|
|
const [tree, setTree] = useState<TreeNode | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const apiBase = import.meta.env.VITE_API_BASE as string;
|
|
|
|
const fetchTree = useCallback(async () => {
|
|
if (!accessToken) return;
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`${apiBase}/graph/tree`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
if (!res.ok) throw new Error(`Graph tree fetch failed: ${res.status}`);
|
|
const data = await res.json();
|
|
setTree(data.tree);
|
|
} catch (err) {
|
|
logger.error('graph-sidebar', 'Failed to load graph tree', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [accessToken, apiBase]);
|
|
|
|
useEffect(() => {
|
|
if (open && !tree && accessToken) fetchTree();
|
|
}, [open, tree, accessToken, fetchTree]);
|
|
|
|
const handleExpand = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
|
|
if (!accessToken) return [];
|
|
const params = new URLSearchParams({
|
|
neo4j_node_id: node.neo4j_node_id,
|
|
neo4j_db_name: node.neo4j_db_name,
|
|
node_type: node.node_type,
|
|
});
|
|
try {
|
|
const res = await fetch(`${apiBase}/graph/node/children?${params}`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return data.children || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}, [accessToken, apiBase]);
|
|
|
|
return (
|
|
<Box sx={{ position: 'relative', height: '100%', display: 'flex', flexShrink: 0 }}>
|
|
<Box
|
|
sx={{
|
|
width: open ? SIDEBAR_WIDTH : 0,
|
|
overflow: 'hidden',
|
|
transition: 'width 0.2s ease',
|
|
bgcolor: 'background.paper',
|
|
borderRight: 1,
|
|
borderColor: 'divider',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
}}
|
|
>
|
|
<Box sx={{ overflowY: 'auto', flexGrow: 1, pt: 1 }}>
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 3 }}>
|
|
<CircularProgress size={20} />
|
|
</Box>
|
|
) : tree ? (
|
|
<TreeItem
|
|
node={tree}
|
|
depth={0}
|
|
onSelect={n => navigateToNeoNode(n)}
|
|
onExpand={handleExpand}
|
|
activeRoomId={context.node?.id}
|
|
/>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
<Tooltip title={open ? 'Collapse sidebar' : 'Expand sidebar'} placement="right">
|
|
<IconButton
|
|
onClick={onToggle}
|
|
size="small"
|
|
sx={{
|
|
position: 'absolute',
|
|
right: -14,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
zIndex: 10,
|
|
bgcolor: 'background.paper',
|
|
border: 1,
|
|
borderColor: 'divider',
|
|
width: 22,
|
|
height: 44,
|
|
borderRadius: '0 4px 4px 0',
|
|
'&:hover': { bgcolor: 'action.hover' },
|
|
}}
|
|
>
|
|
{open
|
|
? <ChevronLeft sx={{ fontSize: 14 }} />
|
|
: <ChevronRight sx={{ fontSize: 14 }} />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
);
|
|
}
|