Files
app/src/components/navigation/GraphSidebar.tsx
T
kcarandClaude Sonnet 4.6 b0c7758135 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]>
2026-05-26 01:25:15 +01:00

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>
);
}