feat(phase-b): Supabase navigation store, snapshot service, auth wiring
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]>
This commit is contained in:
@@ -1,458 +1,121 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Box,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Button,
|
||||
styled
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IconButton, Tooltip, Box, Menu, MenuItem,
|
||||
ListItemIcon, ListItemText, Chip, styled,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
ArrowForward as ArrowForwardIcon,
|
||||
History as HistoryIcon,
|
||||
School as SchoolIcon,
|
||||
Person as PersonIcon,
|
||||
AccountCircle as AccountCircleIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
School as TeachingIcon,
|
||||
Business as BusinessIcon,
|
||||
AccountTree as DepartmentIcon,
|
||||
Class as ClassIcon,
|
||||
ExpandMore as ExpandMoreIcon
|
||||
Home as HomeIcon,
|
||||
CalendarToday,
|
||||
DateRange,
|
||||
Event,
|
||||
WorkspacesOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigationStore } from '../../stores/navigationStore';
|
||||
import { useNeoUser } from '../../contexts/NeoUserContext';
|
||||
import { NAVIGATION_CONTEXTS } from '../../config/navigationContexts';
|
||||
import {
|
||||
BaseContext,
|
||||
ViewContext
|
||||
} from '../../types/navigation';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const NavigationRoot = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const NavigationControls = styled(Box)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const ContextToggleContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(0.5),
|
||||
gap: theme.spacing(0.5),
|
||||
'& .button-label': {
|
||||
'@media (max-width: 500px)': {
|
||||
display: 'none'
|
||||
}
|
||||
function getNodeIcon(nodeType: string) {
|
||||
switch (nodeType) {
|
||||
case 'User': return <HomeIcon fontSize="small" />;
|
||||
case 'CalendarYear': return <CalendarToday fontSize="small" />;
|
||||
case 'CalendarMonth': return <DateRange fontSize="small" />;
|
||||
case 'CalendarDay': return <Event fontSize="small" />;
|
||||
default: return <WorkspacesOutlined fontSize="small" />;
|
||||
}
|
||||
}));
|
||||
|
||||
const ContextToggleButton = styled(Button, {
|
||||
shouldForwardProp: (prop) => prop !== 'active'
|
||||
})<{ active?: boolean }>(({ theme, active }) => ({
|
||||
minWidth: 0,
|
||||
padding: theme.spacing(0.5, 1.5),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: active ? theme.palette.primary.main : 'transparent',
|
||||
color: active ? theme.palette.primary.contrastText : theme.palette.text.primary,
|
||||
textTransform: 'none',
|
||||
transition: theme.transitions.create(['background-color', 'color'], {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
'&:hover': {
|
||||
backgroundColor: active ? theme.palette.primary.dark : theme.palette.action.hover,
|
||||
},
|
||||
'@media (max-width: 500px)': {
|
||||
padding: theme.spacing(0.5),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export const GraphNavigator: React.FC = () => {
|
||||
const {
|
||||
context,
|
||||
switchContext,
|
||||
goBack,
|
||||
goForward,
|
||||
isLoading
|
||||
} = useNavigationStore();
|
||||
|
||||
const { userDbName, workerDbName, isInitialized: isNeoUserInitialized } = useNeoUser();
|
||||
|
||||
const [contextMenuAnchor, setContextMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const { context, goBack, goForward, isLoading } = useNavigationStore();
|
||||
const [historyMenuAnchor, setHistoryMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [availableWidth, setAvailableWidth] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateAvailableSpace = () => {
|
||||
if (!rootRef.current) return;
|
||||
|
||||
// Get the header element
|
||||
const header = rootRef.current.closest('.MuiToolbar-root');
|
||||
if (!header) return;
|
||||
|
||||
// Get the title and menu elements
|
||||
const title = header.querySelector('.app-title');
|
||||
const menu = header.querySelector('.menu-button');
|
||||
|
||||
if (!title || !menu) return;
|
||||
|
||||
// Calculate available width
|
||||
const headerWidth = header.clientWidth;
|
||||
const titleWidth = title.clientWidth;
|
||||
const menuWidth = menu.clientWidth;
|
||||
const padding = 48; // Increased buffer space
|
||||
|
||||
const newAvailableWidth = headerWidth - titleWidth - menuWidth - padding;
|
||||
console.log('Available width:', newAvailableWidth); // Debug log
|
||||
setAvailableWidth(newAvailableWidth);
|
||||
};
|
||||
|
||||
// Set up ResizeObserver
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// Use requestAnimationFrame to debounce calculations
|
||||
window.requestAnimationFrame(calculateAvailableSpace);
|
||||
});
|
||||
|
||||
// Observe both the root element and the header
|
||||
if (rootRef.current) {
|
||||
const header = rootRef.current.closest('.MuiToolbar-root');
|
||||
if (header) {
|
||||
resizeObserver.observe(header);
|
||||
resizeObserver.observe(rootRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial calculation
|
||||
calculateAvailableSpace();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Helper function to determine what should be visible
|
||||
const getVisibility = () => {
|
||||
// Adjusted thresholds and collapse order:
|
||||
// 1. Navigation controls (back/forward/history) collapse first
|
||||
// 2. Toggle labels collapse second
|
||||
// 3. Context label collapses last
|
||||
if (availableWidth < 300) {
|
||||
return {
|
||||
navigation: false,
|
||||
contextLabel: true, // Keep context label visible longer
|
||||
toggleLabels: false
|
||||
};
|
||||
} else if (availableWidth < 450) {
|
||||
return {
|
||||
navigation: false,
|
||||
contextLabel: true, // Keep context label visible
|
||||
toggleLabels: true
|
||||
};
|
||||
} else if (availableWidth < 600) {
|
||||
return {
|
||||
navigation: true,
|
||||
contextLabel: true,
|
||||
toggleLabels: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
navigation: true,
|
||||
contextLabel: true,
|
||||
toggleLabels: true
|
||||
};
|
||||
};
|
||||
|
||||
const visibility = getVisibility();
|
||||
|
||||
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setHistoryMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleHistoryClose = () => {
|
||||
setHistoryMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handleHistoryItemClick = (index: number) => {
|
||||
const {currentIndex} = context.history;
|
||||
const steps = index - currentIndex;
|
||||
|
||||
if (steps < 0) {
|
||||
for (let i = 0; i < -steps; i++) {
|
||||
goBack();
|
||||
}
|
||||
} else if (steps > 0) {
|
||||
for (let i = 0; i < steps; i++) {
|
||||
goForward();
|
||||
}
|
||||
}
|
||||
|
||||
handleHistoryClose();
|
||||
};
|
||||
|
||||
const handleContextChange = useCallback(async (newContext: BaseContext) => {
|
||||
try {
|
||||
// Check if trying to access institute contexts without worker database
|
||||
if (['school', 'department', 'class'].includes(newContext) && !workerDbName) {
|
||||
logger.error('navigation', '❌ Cannot switch to institute context: missing worker database');
|
||||
return;
|
||||
}
|
||||
// Check if trying to access profile contexts without user database
|
||||
if (['profile', 'calendar', 'teaching'].includes(newContext) && !userDbName) {
|
||||
logger.error('navigation', '❌ Cannot switch to profile context: missing user database');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('navigation', '🔄 Changing main context', {
|
||||
from: context.main,
|
||||
to: newContext,
|
||||
userDbName,
|
||||
workerDbName
|
||||
});
|
||||
|
||||
// Get default view for new context
|
||||
const defaultView = getDefaultViewForContext(newContext);
|
||||
|
||||
// Use unified context switch with both base and extended contexts
|
||||
await switchContext({
|
||||
main: ['profile', 'calendar', 'teaching'].includes(newContext) ? 'profile' : 'institute',
|
||||
base: newContext,
|
||||
extended: defaultView,
|
||||
skipBaseContextLoad: false
|
||||
}, userDbName, workerDbName);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to change context:', error);
|
||||
}
|
||||
}, [context.main, switchContext, userDbName, workerDbName]);
|
||||
|
||||
// Helper function to get default view for a context
|
||||
const getDefaultViewForContext = (context: BaseContext): ViewContext => {
|
||||
switch (context) {
|
||||
case 'calendar':
|
||||
return 'overview';
|
||||
case 'teaching':
|
||||
return 'overview';
|
||||
case 'school':
|
||||
return 'overview';
|
||||
case 'department':
|
||||
return 'overview';
|
||||
case 'class':
|
||||
return 'overview';
|
||||
default:
|
||||
return 'overview';
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setContextMenuAnchor(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleContextSelect = useCallback(async (context: BaseContext) => {
|
||||
setContextMenuAnchor(null);
|
||||
try {
|
||||
// Use unified context switch with both base and extended contexts
|
||||
const contextDef = NAVIGATION_CONTEXTS[context];
|
||||
const defaultExtended = contextDef?.views[0]?.id;
|
||||
|
||||
await switchContext({
|
||||
base: context,
|
||||
extended: defaultExtended
|
||||
}, userDbName, workerDbName);
|
||||
} catch (error) {
|
||||
logger.error('navigation', '❌ Failed to select context:', error);
|
||||
}
|
||||
}, [switchContext, userDbName, workerDbName]);
|
||||
|
||||
const getContextItems = useCallback(() => {
|
||||
if (context.main === 'profile') {
|
||||
return [
|
||||
{ id: 'profile', label: 'Profile', icon: AccountCircleIcon },
|
||||
{ id: 'calendar', label: 'Calendar', icon: CalendarIcon },
|
||||
{ id: 'teaching', label: 'Teaching', icon: TeachingIcon },
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{ id: 'school', label: 'School', icon: BusinessIcon },
|
||||
{ id: 'department', label: 'Department', icon: DepartmentIcon },
|
||||
{ id: 'class', label: 'Class', icon: ClassIcon },
|
||||
];
|
||||
}
|
||||
}, [context.main]);
|
||||
|
||||
const getContextIcon = useCallback((contextType: string) => {
|
||||
switch (contextType) {
|
||||
case 'profile':
|
||||
return <AccountCircleIcon />;
|
||||
case 'calendar':
|
||||
return <CalendarIcon />;
|
||||
case 'teaching':
|
||||
return <TeachingIcon />;
|
||||
case 'school':
|
||||
return <BusinessIcon />;
|
||||
case 'department':
|
||||
return <DepartmentIcon />;
|
||||
case 'class':
|
||||
return <ClassIcon />;
|
||||
default:
|
||||
return <AccountCircleIcon />;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isDisabled = !isNeoUserInitialized || isLoading;
|
||||
const { history } = context;
|
||||
const canGoBack = history.currentIndex > 0;
|
||||
const canGoForward = history.currentIndex < history.nodes.length - 1;
|
||||
const currentNode = context.node;
|
||||
|
||||
const handleHistoryClick = (e: React.MouseEvent<HTMLElement>) => setHistoryMenuAnchor(e.currentTarget);
|
||||
const handleHistoryClose = () => setHistoryMenuAnchor(null);
|
||||
const handleHistoryItemClick = (index: number) => {
|
||||
const delta = index - history.currentIndex;
|
||||
if (delta < 0) for (let i = 0; i < -delta; i++) goBack();
|
||||
else if (delta > 0) for (let i = 0; i < delta; i++) goForward();
|
||||
handleHistoryClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationRoot ref={rootRef}>
|
||||
<NavigationControls sx={{ display: visibility.navigation ? 'flex' : 'none' }}>
|
||||
<Tooltip title="Back">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack || isDisabled}
|
||||
size="small"
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<NavigationRoot>
|
||||
<Tooltip title="Back">
|
||||
<span>
|
||||
<IconButton onClick={goBack} disabled={!canGoBack || isLoading} size="small">
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="History">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={handleHistoryClick}
|
||||
disabled={!history.nodes.length || isDisabled}
|
||||
size="small"
|
||||
>
|
||||
<HistoryIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="History">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={handleHistoryClick}
|
||||
disabled={!history.nodes.length}
|
||||
size="small"
|
||||
>
|
||||
<HistoryIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Forward">
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward || isDisabled}
|
||||
size="small"
|
||||
>
|
||||
<ArrowForwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</NavigationControls>
|
||||
<Tooltip title="Forward">
|
||||
<span>
|
||||
<IconButton onClick={goForward} disabled={!canGoForward || isLoading} size="small">
|
||||
<ArrowForwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{currentNode && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={getNodeIcon(currentNode.type)}
|
||||
label={currentNode.label || currentNode.type}
|
||||
variant="outlined"
|
||||
sx={{ maxWidth: 200, fontSize: '0.75rem' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* History Menu */}
|
||||
<Menu
|
||||
anchorEl={historyMenuAnchor}
|
||||
open={Boolean(historyMenuAnchor)}
|
||||
onClose={handleHistoryClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
{history.nodes.map((node, index) => (
|
||||
<MenuItem
|
||||
key={`${node.id}-${index}`}
|
||||
onClick={() => handleHistoryItemClick(index)}
|
||||
selected={index === history.currentIndex}
|
||||
dense
|
||||
>
|
||||
<ListItemIcon>
|
||||
{getContextIcon(node.type)}
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
{getNodeIcon(node.type)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
<ListItemText
|
||||
primary={node.label || node.id}
|
||||
secondary={node.type}
|
||||
primaryTypographyProps={{ fontSize: '0.8rem' }}
|
||||
secondaryTypographyProps={{ fontSize: '0.7rem' }}
|
||||
/>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<ContextToggleContainer>
|
||||
<ContextToggleButton
|
||||
active={context.main === 'profile'}
|
||||
onClick={() => handleContextChange('profile' as BaseContext)}
|
||||
startIcon={<PersonIcon />}
|
||||
disabled={isDisabled || !userDbName}
|
||||
>
|
||||
{visibility.toggleLabels && <span className="button-label">Profile</span>}
|
||||
</ContextToggleButton>
|
||||
<ContextToggleButton
|
||||
active={context.main === 'institute'}
|
||||
onClick={() => handleContextChange('school' as BaseContext)}
|
||||
startIcon={<SchoolIcon />}
|
||||
disabled={isDisabled || !workerDbName}
|
||||
>
|
||||
{visibility.toggleLabels && <span className="button-label">Institute</span>}
|
||||
</ContextToggleButton>
|
||||
</ContextToggleContainer>
|
||||
|
||||
<Box>
|
||||
<Tooltip title={context.base}>
|
||||
<span>
|
||||
<Button
|
||||
onClick={handleContextMenu}
|
||||
disabled={isDisabled}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
p: 0.5,
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getContextIcon(context.base)}
|
||||
{visibility.contextLabel && (
|
||||
<Box sx={{ ml: 1 }}>
|
||||
{context.base}
|
||||
</Box>
|
||||
)}
|
||||
<ExpandMoreIcon sx={{ ml: visibility.contextLabel ? 0.5 : 0 }} />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={contextMenuAnchor}
|
||||
open={Boolean(contextMenuAnchor)}
|
||||
onClose={() => setContextMenuAnchor(null)}
|
||||
>
|
||||
{getContextItems().map(item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleContextSelect(item.id as BaseContext)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<item.icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</NavigationRoot>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user