Initial commit

This commit is contained in:
2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions
@@ -0,0 +1,375 @@
import React, { useEffect, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { TldrawUiButton } from '@tldraw/tldraw';
import {
Button,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
styled,
ThemeProvider,
createTheme,
useMediaQuery
} from '@mui/material';
import {
PushPin as PushPinIcon,
PushPinOutlined as PushPinOutlinedIcon,
ExpandMore as ExpandMoreIcon,
Category as ShapesIcon,
Slideshow as SlidesIcon,
YouTube as YouTubeIcon,
AccountTree as GraphIcon,
Search as SearchIcon,
Navigation as NavigationIcon,
Save as NodeIcon,
Assignment as ExamIcon
} from '@mui/icons-material';
import { CCShapesPanel } from './CCShapesPanel';
import { CCSlidesPanel } from './CCSlidesPanel';
import { CCYoutubePanel } from './CCYoutubePanel';
import { CCGraphPanel } from './CCGraphPanel';
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
import { CCSearchPanel } from './CCSearchPanel'
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
import './panel.css';
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
import { BaseContext, ViewContext } from '../../../../../types/navigation';
// import { CCNodeSnapshotPanel } from './navigation/CCNodeSnapshotPanel';
import { useTLDraw } from '../../../../../contexts/TLDrawContext';
export const PANEL_TYPES = {
default: [
{ id: 'navigation', label: 'Navigation', order: 10 },
{ id: 'node-snapshot', label: 'Node', order: 20 },
{ id: 'cc-shapes', label: 'Shapes', order: 30 },
{ id: 'slides', label: 'Slides', order: 40 },
{ id: 'youtube', label: 'YouTube', order: 50 },
{ id: 'graph', label: 'Graph', order: 60 },
{ id: 'search', label: 'Search', order: 70 },
],
examMarker: [
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
],
} as const;
export type PanelType = typeof PANEL_TYPES.default[number]['id'] | typeof PANEL_TYPES.examMarker[number]['id'];
interface BasePanelProps {
initialPanelType?: PanelType;
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
isExpanded?: boolean;
isPinned?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onPinnedChange?: (pinned: boolean) => void;
currentContext?: BaseContext;
onContextChange?: (context: BaseContext) => void;
currentExtendedContext?: ViewContext;
onExtendedContextChange?: (context: ViewContext) => void;
isMenuOpen?: boolean;
onMenuOpenChange?: (open: boolean) => void;
}
const PanelTypeButton = styled(Button)(() => ({
textTransform: 'none',
padding: '6px 12px',
gap: '8px',
backgroundColor: 'var(--color-panel)',
color: 'var(--color-text)',
border: '1px solid transparent',
transition: 'border-color 200ms ease',
justifyContent: 'space-between',
minWidth: '200px',
'&:hover': {
backgroundColor: 'var(--color-panel)',
borderColor: 'var(--color-text)',
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
color: 'inherit',
}
}));
const StyledMenuItem = styled(MenuItem)(() => ({
gap: '8px',
padding: '8px 16px',
transition: 'background-color 200ms ease',
'&:hover': {
backgroundColor: 'var(--color-hover)',
'& .MuiListItemIcon-root': {
color: 'var(--color-selected)',
}
},
'& .MuiListItemIcon-root': {
color: 'var(--color-text)',
minWidth: '32px',
transition: 'color 200ms ease',
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
}
}
}));
export const BasePanel: React.FC<BasePanelProps> = ({
initialPanelType = 'cc-shapes',
examMarkerProps,
isExpanded: controlledIsExpanded,
isPinned: controlledIsPinned,
onExpandedChange,
onPinnedChange,
isMenuOpen = false,
onMenuOpenChange = () => {},
}) => {
const location = useLocation();
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: {
mode,
divider: 'var(--color-divider)',
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const isExamMarkerRoute = location.pathname === '/exam-marker';
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
isExamMarkerRoute ? 'exam-marker' : initialPanelType
);
// Use controlled state if provided, otherwise use internal state
const [internalIsExpanded, setInternalIsExpanded] = React.useState(false);
const [internalIsPinned, setInternalIsPinned] = React.useState(false);
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
const isPinned = controlledIsPinned ?? internalIsPinned;
const handleExpandedChange = (expanded: boolean) => {
setInternalIsExpanded(expanded);
onExpandedChange?.(expanded);
};
const handlePinToggle = () => {
const newPinned = !isPinned;
setInternalIsPinned(newPinned);
onPinnedChange?.(newPinned);
};
const panelRef = useRef<HTMLDivElement>(null);
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// Don't close if pinned
if (isPinned) return;
// Check if click is outside panel
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
// Check if click is not on a panel-related element
const target = event.target as HTMLElement;
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
if (isClickOutside && !isPanelElement) {
handleExpandedChange(false);
}
};
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isExpanded, isPinned]);
const getIconForPanel = (panelId: PanelType) => {
switch (panelId) {
case 'cc-shapes':
return <ShapesIcon />;
case 'slides':
return <SlidesIcon />;
case 'youtube':
return <YouTubeIcon />;
case 'graph':
return <GraphIcon />;
case 'search':
return <SearchIcon />;
case 'navigation':
return <NavigationIcon />;
case 'node-snapshot':
return <NodeIcon />;
case 'exam-marker':
return <ExamIcon />;
default:
return <ShapesIcon />;
}
};
const getDescriptionForPanel = (panelId: PanelType) => {
switch (panelId) {
case 'cc-shapes':
return 'Add shapes and elements to your canvas';
case 'slides':
return 'Manage presentation slides';
case 'youtube':
return 'Embed YouTube videos';
case 'graph':
return 'View and manage graph connections';
case 'search':
return 'Search through your content';
case 'navigation':
return 'Navigate through different contexts';
case 'node-snapshot':
return 'Manage node snapshots';
case 'exam-marker':
return 'Mark and grade exams';
default:
return '';
}
};
const renderCurrentPanel = () => {
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
}
switch (currentPanelType) {
case 'cc-shapes':
return <CCShapesPanel />;
case 'slides':
return <CCSlidesPanel />;
case 'youtube':
return <CCYoutubePanel />;
case 'graph':
return <CCGraphPanel />;
case 'search':
return <CCSearchPanel />;
default:
return null;
}
};
// Handle menu button click
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
onMenuOpenChange(true);
};
// Handle menu close
const handleMenuClose = () => {
setMenuAnchorEl(null);
onMenuOpenChange(false);
};
return (
<>
{!isExpanded && (
<div
className="panel-handle"
onClick={() => handleExpandedChange(true)}
onTouchEnd={(e) => {
e.stopPropagation();
handleExpandedChange(true);
}}
>
</div>
)}
{isExpanded && (
<div
ref={panelRef}
className="panel-root"
style={{
top: dimensions.topOffset,
height: `calc(100% - ${dimensions.bottomOffset})`,
width: dimensions.width,
zIndex: Z_INDICES.PANEL,
}}
>
<div className="panel-header">
<ThemeProvider theme={theme}>
<PanelTypeButton
onClick={handleMenuClick}
endIcon={<ExpandMoreIcon />}
startIcon={getIconForPanel(currentPanelType)}
>
{availablePanels.find(p => p.id === currentPanelType)?.label}
</PanelTypeButton>
<Menu
anchorEl={menuAnchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
PaperProps={{
elevation: 8,
sx: {
border: '1px solid var(--color-divider)',
boxShadow: 'var(--shadow-popup)',
}
}}
>
{[...availablePanels]
.sort((a, b) => a.order - b.order)
.map(type => (
<StyledMenuItem
key={type.id}
onClick={() => {
setCurrentPanelType(type.id as PanelType);
handleMenuClose();
}}
selected={currentPanelType === type.id}
>
<ListItemIcon>
{getIconForPanel(type.id as PanelType)}
</ListItemIcon>
<ListItemText
primary={type.label}
secondary={getDescriptionForPanel(type.id as PanelType)}
primaryTypographyProps={{
sx: { color: 'var(--color-text)' }
}}
secondaryTypographyProps={{
sx: { color: 'var(--color-text-secondary)' }
}}
/>
</StyledMenuItem>
))}
</Menu>
</ThemeProvider>
<div className="panel-header-actions">
<TldrawUiButton
type="icon"
onClick={handlePinToggle}
className="pin-button"
>
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
</TldrawUiButton>
</div>
</div>
<div className="panel-content">
{renderCurrentPanel()}
</div>
</div>
)}
</>
);
};
@@ -0,0 +1,495 @@
import React, { useState } from 'react';
import { Box, Button, Typography, Divider, Stack } from '@mui/material';
import { Editor, exportToBlob, TLPageId, Box as TLBox } from '@tldraw/tldraw';
import { PDFDocument } from 'pdf-lib';
import { Pdf } from '../../../../../pages/tldraw/CCExamMarker/types';
import { logger } from '../../../../../debugConfig';
interface CCExamMarkerPanelProps {
editor: Editor | null;
currentView: 'exam-and-markscheme' | 'student-responses';
onViewChange: (view: 'exam-and-markscheme' | 'student-responses') => void;
currentStudentIndex: number;
totalStudents: number;
onPreviousStudent: () => void;
onNextStudent: () => void;
getCurrentPdf: () => Pdf | null;
}
export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
editor,
currentView,
onViewChange,
currentStudentIndex,
totalStudents,
onPreviousStudent,
onNextStudent,
getCurrentPdf,
}) => {
const [exportProgress, setExportProgress] = useState<number | null>(null);
const exportPdf = async (
editor: Editor,
{ name, source, pages }: Pdf,
onProgress: (progress: number) => void,
startPage?: number,
endPage?: number,
studentIndex?: number
) => {
logger.debug('cc-exam-marker', '📤 Starting PDF export', {
name,
startPage,
endPage,
studentIndex,
currentView,
totalPages: pages.length
});
const pdfPages = pages.slice(startPage, endPage);
logger.debug('cc-exam-marker', '📄 Selected pages for export', {
pdfPages: pdfPages.length,
pageIndices: pdfPages.map((_, i) => (startPage || 0) + i)
});
const totalThings = pdfPages.length * 2 + 2;
let progressCount = 0;
const tickProgress = () => {
progressCount++;
onProgress(progressCount / totalThings);
};
const sourcePdf = await PDFDocument.load(source);
tickProgress();
// Create a new PDF document for the selected pages
const newPdf = await PDFDocument.create();
// Copy pages from source PDF
const pageIndices = pdfPages.map((_, i) => (startPage || 0) + i);
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
copiedPages.forEach(page => newPdf.addPage(page));
tickProgress();
// Store current page to restore later
const currentPageId = editor.getCurrentPageId();
logger.debug('cc-exam-marker', '📍 Current page before export', { currentPageId });
// Switch to the correct page based on context
const targetPageId = (studentIndex !== undefined
? `page:student-response-${studentIndex}`
: currentView === 'exam-and-markscheme'
? 'page:exam-page'
: 'page:mark-scheme-page') as TLPageId;
logger.debug('cc-exam-marker', '🎯 Switching to target page', { targetPageId });
editor.setCurrentPage(targetPageId);
// Get all shape IDs that are not page shapes (i.e., annotations)
const pageShapeIds = new Set(pages.map(page => page.shapeId));
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
logger.debug('cc-exam-marker', '📝 Found shapes on current page', {
totalShapes: editor.getCurrentPageShapeIds().size,
pageShapes: pageShapeIds.size,
annotationShapes: allShapeIds.length
});
// For each page, draw annotations on top
for (let i = 0; i < pdfPages.length; i++) {
const page = pdfPages[i];
const pdfPage = newPdf.getPages()[i];
const {bounds} = page;
logger.debug('cc-exam-marker', `📄 Processing page ${i + 1}/${pdfPages.length}`, {
bounds,
pageIndex: i,
globalPageIndex: (startPage || 0) + i
});
// Get shapes that intersect with this page using editor's bounds checking
const shapesInBounds = allShapeIds.filter((id) => {
const shape = editor.getShape(id);
if (!shape || shape.isLocked) return false;
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
const annotationManager = editor.annotationManager;
const annotationData = annotationManager.getAnnotationData(id);
if (!annotationData) return false;
// Filter by student index if provided
if (studentIndex !== undefined && annotationData.studentIndex !== studentIndex) {
return false;
}
// For exam/markscheme view, only include those annotations
if (studentIndex === undefined && annotationData.studentIndex !== undefined) {
return false;
}
// For individual student exports, use the annotation's original page index
// For full exports, use the stored page index
const adjustedPageIndex = annotationData.pageIndex;
// Check if this shape belongs to this page index
if (adjustedPageIndex !== i) {
return false;
}
logger.debug('cc-exam-marker', `🔍 Found matching annotation`, {
shapeId: id,
annotationData,
adjustedPageIndex,
currentPageIndex: i,
bounds: editor.getShapePageBounds(id)
});
return true;
});
logger.debug('cc-exam-marker', `✨ Found shapes for page ${i + 1}`, {
shapesInBounds: shapesInBounds.length,
pageIndex: i,
globalPageIndex: (startPage || 0) + i
});
if (shapesInBounds.length === 0) {
tickProgress();
tickProgress();
continue;
}
// Export the annotations as PNG
const exportedPng = await exportToBlob({
editor,
ids: shapesInBounds,
format: 'png',
opts: {
background: false,
// Create a new bounds that's relative to the current page
bounds: new TLBox(
bounds.x,
0, // Reset to 0 since we want annotations relative to current page
bounds.width,
bounds.height
),
padding: 0,
scale: 1
},
});
tickProgress();
// Draw the annotations on the PDF page
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
const pdfWidth = pdfPage.getWidth();
const pdfHeight = pdfPage.getHeight();
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfWidth,
height: pdfHeight,
});
tickProgress();
}
// Restore original page
logger.debug('cc-exam-marker', '🔄 Restoring original page', { currentPageId });
editor.setCurrentPage(currentPageId);
const pdfBytes = await newPdf.save();
const url = URL.createObjectURL(
new Blob([pdfBytes], { type: 'application/pdf' })
);
tickProgress();
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
logger.debug('cc-exam-marker', '✅ PDF export completed', { name });
};
const handleExportCurrentView = async () => {
if (!editor) return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
setExportProgress(0);
try {
if (currentView === 'student-responses') {
// For student responses, we need to handle each student's annotations separately
const pagesPerStudent = currentPdf.pages.length / totalStudents;
let currentProgress = 0;
// Create a new PDF with all pages
const sourcePdf = await PDFDocument.load(currentPdf.source);
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(sourcePdf, Array.from({ length: currentPdf.pages.length }, (_, i) => i));
copiedPages.forEach(page => newPdf.addPage(page));
// For each student, export their annotations onto their pages
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
// Switch to the student's page to get their annotations
const targetPageId = `page:student-response-${studentIndex}` as TLPageId;
editor.setCurrentPage(targetPageId);
// Get all annotations for this student
const pageShapeIds = new Set(currentPdf.pages.map(page => page.shapeId));
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
// Process each page for this student
for (let i = 0; i < pagesPerStudent; i++) {
const pageIndex = startPage + i;
const page = currentPdf.pages[pageIndex];
const pdfPage = newPdf.getPages()[pageIndex];
// Get shapes for this page
const shapesInBounds = allShapeIds.filter((id) => {
const shape = editor.getShape(id);
if (!shape || shape.isLocked) return false;
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
const annotationManager = editor.annotationManager;
const annotationData = annotationManager.getAnnotationData(id);
if (!annotationData) return false;
return annotationData.studentIndex === studentIndex && annotationData.pageIndex === i;
});
if (shapesInBounds.length > 0) {
// Export and draw annotations
const exportedPng = await exportToBlob({
editor,
ids: shapesInBounds,
format: 'png',
opts: {
background: false,
bounds: new TLBox(
page.bounds.x,
0,
page.bounds.width,
page.bounds.height
),
padding: 0,
scale: 1
},
});
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
pdfPage.drawImage(pngImage, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight(),
});
}
currentProgress++;
setExportProgress(currentProgress / (totalStudents * pagesPerStudent));
}
}
// Save the combined PDF
const pdfBytes = await newPdf.save();
const url = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
const a = document.createElement('a');
a.href = url;
a.download = currentPdf.name;
a.click();
URL.revokeObjectURL(url);
} else {
// For exam/mark scheme view, use the original export logic
await exportPdf(editor, currentPdf, setExportProgress);
}
} finally {
setExportProgress(null);
}
};
const handleExportCurrentStudent = async () => {
if (!editor || currentView !== 'student-responses') return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
const pagesPerStudent = currentPdf.pages.length / totalStudents;
const startPage = currentStudentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
setExportProgress(0);
try {
await exportPdf(
editor,
{
...currentPdf,
name: `Student_${currentStudentIndex + 1}_Response.pdf`,
},
setExportProgress,
Math.floor(startPage),
Math.floor(endPage),
currentStudentIndex
);
} finally {
setExportProgress(null);
}
};
const handleBatchExport = async () => {
if (!editor || currentView !== 'student-responses') return;
const currentPdf = getCurrentPdf();
if (!currentPdf) return;
setExportProgress(0);
try {
const pagesPerStudent = currentPdf.pages.length / totalStudents;
let currentProgress = 0;
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
await exportPdf(
editor,
{
...currentPdf,
name: `Student_${studentIndex + 1}_Response.pdf`,
},
setExportProgress,
Math.floor(startPage),
Math.floor(endPage),
studentIndex
);
currentProgress++;
setExportProgress(currentProgress / totalStudents);
}
} finally {
setExportProgress(null);
}
};
return (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Exam Marker
</Typography>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
View Mode
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
fullWidth
variant={currentView === 'exam-and-markscheme' ? 'contained' : 'outlined'}
onClick={() => onViewChange('exam-and-markscheme')}
>
Exam & Mark Scheme
</Button>
<Button
fullWidth
variant={currentView === 'student-responses' ? 'contained' : 'outlined'}
onClick={() => onViewChange('student-responses')}
>
Student Responses
</Button>
</Box>
</Box>
{currentView === 'student-responses' && (
<>
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Student Navigation
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
fullWidth
variant="outlined"
onClick={onPreviousStudent}
disabled={currentStudentIndex === 0}
>
Previous Student
</Button>
<Typography variant="body2" align="center">
Student {currentStudentIndex + 1} of {totalStudents}
</Typography>
<Button
fullWidth
variant="outlined"
onClick={onNextStudent}
disabled={currentStudentIndex === totalStudents - 1}
>
Next Student
</Button>
</Box>
</Box>
</>
)}
<Divider />
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Actions
</Typography>
<Stack spacing={1}>
<Button
fullWidth
variant="contained"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleExportCurrentView}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: `Export ${currentView === 'exam-and-markscheme' ? 'All' : 'Combined Responses'}`}
</Button>
{currentView === 'student-responses' && (
<>
<Button
fullWidth
variant="outlined"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleExportCurrentStudent}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export Current Student'}
</Button>
<Button
fullWidth
variant="outlined"
color="primary"
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
onClick={handleBatchExport}
>
{exportProgress !== null
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export All as Separate Files'}
</Button>
</>
)}
</Stack>
</Box>
<Box sx={{ mt: 'auto' }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Statistics
</Typography>
<Typography variant="body2">
Total Pages: {getCurrentPdf()?.pages.length || 0}
</Typography>
</Box>
</Box>
);
};
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { useEditor } from '@tldraw/tldraw';
import { createGraphShape, createUserNodeFromProfile } from '../../../cc-base/shape-helpers/graph-helpers';
import { ccGraphShapeProps, getDefaultCCUserNodeProps } from '../../../cc-base/cc-graph/cc-graph-props';
import { useNeoUser } from '../../../../../contexts/NeoUserContext';
import { logger } from '../../../../../debugConfig';
import './panel.css';
import { GraphShapeType } from '../../../cc-base/cc-graph/cc-graph-types';
export const CCGraphPanel = () => {
const editor = useEditor();
const { userNode } = useNeoUser();
const [isOpen, setIsOpen] = useState(false);
const graphShapeTypes = Object.keys(ccGraphShapeProps);
const handleShapeSelect = (shapeType: GraphShapeType) => {
if (shapeType === 'cc-user-node') {
if (!userNode) {
logger.warn('graph-panel', '⚠️ Cannot create user node - no user data available')
return;
}
const defaultProps = getDefaultCCUserNodeProps();
createUserNodeFromProfile(editor, {
...defaultProps,
...userNode
});
} else {
createGraphShape(editor, shapeType);
}
setIsOpen(false);
};
return (
<div className="panel-container">
<div className="panel-section">
<button
className="shape-button"
onClick={() => setIsOpen(!isOpen)}
>
Add Graph Node
</button>
{isOpen && (
<div className="panel-dropdown">
{graphShapeTypes.map((shapeType) => (
<button
key={shapeType}
className="shape-button"
onClick={() => handleShapeSelect(shapeType as GraphShapeType)}
>
{shapeType.replace('cc-', '').replace('-node', '')}
</button>
))}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,300 @@
import React, { useState, useCallback, useMemo } from 'react'
import { useEditor } from '@tldraw/tldraw'
import {
TextField,
IconButton,
List,
ListItem,
ListItemText,
Paper,
Button,
Tabs,
Tab,
ThemeProvider,
createTheme,
useMediaQuery,
styled
} from '@mui/material'
import SearchIcon from '@mui/icons-material/Search'
import AddBoxIcon from '@mui/icons-material/AddBox'
import LanguageIcon from '@mui/icons-material/Language'
import { SearchResult } from '../../../../../services/tldraw/searchService'
import { createSearchShape } from '../../../cc-base/shape-helpers/search-helpers'
import { createWebBrowserShape } from '../../../cc-base/shape-helpers/web-browser-helpers'
import { SearchService } from '../../../../../services/tldraw/searchService'
import { CCWebBrowserShapeUtil } from '../../../cc-base/cc-web-browser/CCWebBrowserUtil'
import { useTLDraw } from '../../../../../contexts/TLDrawContext'
const StyledTextField = styled(TextField)(() => ({
'& .MuiInputBase-root': {
backgroundColor: 'var(--color-panel)',
color: 'var(--color-text)',
'& fieldset': {
borderColor: 'var(--color-divider)',
},
'&:hover fieldset': {
borderColor: 'var(--color-text)',
},
'&.Mui-focused fieldset': {
borderColor: 'var(--color-selected)',
},
},
'& .MuiInputBase-input': {
'&::placeholder': {
color: 'var(--color-text-secondary)',
opacity: 1,
},
},
}));
const StyledButton = styled(Button)(() => ({
textTransform: 'none',
backgroundColor: 'var(--color-panel)',
color: 'var(--color-text)',
border: '1px solid var(--color-divider)',
'&:hover': {
backgroundColor: 'var(--color-hover)',
borderColor: 'var(--color-text)',
},
'&.Mui-disabled': {
backgroundColor: 'var(--color-muted)',
color: 'var(--color-text-disabled)',
borderColor: 'var(--color-divider)',
},
}));
const StyledIconButton = styled(IconButton)(() => ({
color: 'var(--color-text)',
'&:hover': {
backgroundColor: 'var(--color-hover)',
},
'&.Mui-disabled': {
color: 'var(--color-text-disabled)',
},
}));
const StyledPaper = styled(Paper)(() => ({
backgroundColor: 'var(--color-panel)',
border: '1px solid var(--color-divider)',
'& .MuiListItem-root': {
borderBottom: '1px solid var(--color-divider)',
'&:last-child': {
borderBottom: 'none',
},
},
}));
const StyledTabs = styled(Tabs)(() => ({
'& .MuiTab-root': {
color: 'var(--color-text-secondary)',
textTransform: 'none',
'&.Mui-selected': {
color: 'var(--color-selected)',
},
},
'& .MuiTabs-indicator': {
backgroundColor: 'var(--color-selected)',
},
}));
export const CCSearchPanel: React.FC = () => {
const editor = useEditor()
const { tldrawPreferences } = useTLDraw()
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)')
const [query, setQuery] = useState('')
const [url, setUrl] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [results, setResults] = useState<SearchResult[]>([])
const [activeTab, setActiveTab] = useState(0)
const theme = useMemo(() => {
let mode: 'light' | 'dark';
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: {
mode,
divider: 'var(--color-divider)',
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const handleSearch = useCallback(async () => {
if (!query.trim()) return
setIsSearching(true)
try {
const searchResults = await SearchService.search(query)
setResults(searchResults)
} catch (error) {
console.error('Search error:', error)
setResults([])
} finally {
setIsSearching(false)
}
}, [query])
const handleCreateSearchShape = useCallback(() => {
createSearchShape(editor, { query })
}, [editor, query])
const handleCreateBrowserShape = useCallback(() => {
const processedUrl = url.startsWith('http') ? url : `https://${url}`
const { isEmbeddable } = CCWebBrowserShapeUtil.isEmbeddableUrl(processedUrl)
if (isEmbeddable) {
editor.createShape({
type: 'embed',
props: { url: processedUrl },
})
} else {
createWebBrowserShape(editor, { url: processedUrl })
}
}, [editor, url])
const handleCreateFromResult = useCallback((result: SearchResult) => {
const { isEmbeddable } = CCWebBrowserShapeUtil.isEmbeddableUrl(result.url)
if (isEmbeddable) {
editor.createShape({
type: 'embed',
props: { url: result.url },
})
} else {
createWebBrowserShape(editor, { url: result.url })
}
}, [editor])
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (activeTab === 0) {
handleSearch()
} else {
handleCreateBrowserShape()
}
}
}
return (
<ThemeProvider theme={theme}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '8px', gap: '8px' }}>
<StyledTabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
>
<Tab label="Search" />
<Tab label="Browser" />
</StyledTabs>
{activeTab === 0 ? (
<>
<div style={{ display: 'flex', gap: '8px' }}>
<StyledTextField
fullWidth
size="small"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Search..."
disabled={isSearching}
/>
<StyledIconButton
onClick={handleSearch}
disabled={isSearching || !query.trim()}
>
<SearchIcon />
</StyledIconButton>
</div>
<StyledButton
startIcon={<AddBoxIcon />}
onClick={handleCreateSearchShape}
fullWidth
>
Add Search Box
</StyledButton>
<StyledPaper sx={{ flex: 1, overflow: 'auto' }}>
<List>
{results.map((result, index) => (
<ListItem
key={index}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 1,
padding: '12px',
}}
>
<ListItemText
primary={result.title}
secondary={result.content}
primaryTypographyProps={{
sx: {
fontWeight: 'bold',
fontSize: '0.9rem',
color: 'var(--color-selected)',
mb: 0.5
}
}}
secondaryTypographyProps={{
sx: {
fontSize: '0.8rem',
color: 'var(--color-text)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}
}}
/>
<StyledButton
size="small"
onClick={() => handleCreateFromResult(result)}
fullWidth
>
Open in Browser
</StyledButton>
</ListItem>
))}
</List>
</StyledPaper>
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<StyledTextField
fullWidth
size="small"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter URL..."
/>
<StyledIconButton
onClick={handleCreateBrowserShape}
disabled={!url.trim()}
>
<LanguageIcon />
</StyledIconButton>
</div>
<StyledButton
startIcon={<AddBoxIcon />}
onClick={handleCreateBrowserShape}
disabled={!url.trim()}
fullWidth
>
Add Web Browser
</StyledButton>
</div>
)}
</div>
</ThemeProvider>
)
}
@@ -0,0 +1,98 @@
import React, { useRef } from 'react';
import { useEditor } from '@tldraw/tldraw';
import { CC_SHAPE_CONFIGS } from '../../../cc-base/cc-configs';
import { createSlideshowAtCenter, handleSlideshowFileUpload } from '../../../cc-base/shape-helpers/slideshow-helpers';
import { createCalendarShapeAtCenter } from '../../../cc-base/shape-helpers/calendar-helpers';
import { createSettingsShapeAtCenter } from '../../../cc-base/shape-helpers/settings-helpers';
import { createLiveTranscriptionShapeAtCenter } from '../../../cc-base/shape-helpers/transcription-helpers';
import './panel.css';
export const CCShapesPanel: React.FC = () => {
const editor = useEditor();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleCreateShape = (shapeType: keyof typeof CC_SHAPE_CONFIGS, slidePattern?: string, numSlides?: number) => {
if (!editor) return;
switch (shapeType) {
case 'cc-calendar':
createCalendarShapeAtCenter(editor);
break;
case 'cc-settings':
createSettingsShapeAtCenter(editor);
break;
case 'cc-live-transcription':
createLiveTranscriptionShapeAtCenter(editor);
break;
case 'cc-slideshow':
createSlideshowAtCenter(editor, slidePattern, numSlides);
break;
default:
break;
}
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!editor || !event.target.files || event.target.files.length === 0) return;
try {
await handleSlideshowFileUpload(editor, event.target.files[0], () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
});
} catch (error) {
alert(error instanceof Error ? error.message : 'An unknown error occurred');
}
};
return (
<div className="panel-container">
<div className="panel-section">
<button className="shape-button" onClick={() => handleCreateShape('cc-calendar')}>
Calendar Shape
</button>
<button className="shape-button" onClick={() => handleCreateShape('cc-settings')}>
Settings Shape
</button>
<button className="shape-button" onClick={() => handleCreateShape('cc-live-transcription')}>
Live Transcription
</button>
</div>
<div className="panel-divider" />
<div className="panel-section">
<div className="panel-section-title">Slideshow Patterns</div>
<button className="shape-button" onClick={() => handleCreateShape('cc-slideshow', 'horizontal', Number(CC_SHAPE_CONFIGS['cc-slideshow'].defaultProps.numSlides ?? 3))}>
Horizontal Slideshow
</button>
<button className="shape-button" onClick={() => handleCreateShape('cc-slideshow', 'vertical', Number(CC_SHAPE_CONFIGS['cc-slideshow'].defaultProps.numSlides ?? 3))}>
Vertical Slideshow
</button>
<button className="shape-button" onClick={() => handleCreateShape('cc-slideshow', 'grid', Number(CC_SHAPE_CONFIGS['cc-slideshow'].defaultProps.numSlides ?? 3))}>
Grid Slideshow
</button>
<button className="shape-button" onClick={() => handleCreateShape('cc-slideshow', 'radial')}>
Radial Slideshow
</button>
</div>
<div className="panel-divider" />
<div className="panel-section">
<div className="panel-section-title">Import Office Documents</div>
<input
ref={fileInputRef}
type="file"
accept=".pptx,.docx,.pdf"
onChange={handleFileUpload}
style={{ display: 'none' }}
/>
<button className="shape-button" onClick={() => fileInputRef.current?.click()}>
Upload Document
</button>
</div>
</div>
);
};
@@ -0,0 +1,116 @@
import React from 'react'
import { useEditor, TldrawUiButton } from '@tldraw/tldraw'
import {
useSlideShows,
useCurrentSlide,
moveToSlide,
getSlideLabel
} from '../../../cc-base/cc-slideshow/useSlideShow'
import { CCSlideShape } from '../../../cc-base/cc-slideshow/CCSlideShapeUtil'
import { CCSlideShowShape } from '../../../cc-base/cc-slideshow/CCSlideShowShapeUtil'
import { useTLDraw } from '../../../../../contexts/TLDrawContext'
import { logger } from '../../../../../debugConfig'
import { CCSlideLayoutBinding } from '../../../cc-base/cc-slideshow/CCSlideLayoutBindingUtil'
import './panel.css'
export const CCSlidesPanel: React.FC = () => {
const editor = useEditor()
const slideshows = useSlideShows()
const currentSlide = useCurrentSlide()
const { presentationMode, togglePresentationMode, presentationService } = useTLDraw()
const handleSlideClick = (slide: CCSlideShape) => {
logger.info('selection', '🖱️ Slide clicked in panel', {
slideId: slide.id,
timestamp: new Date().toISOString()
})
moveToSlide(editor, slide, presentationMode)
}
const handleTogglePresentation = () => {
logger.info('presentation', '🔄 Toggling presentation mode from slides panel')
togglePresentationMode(editor)
}
const handleZoomToSlideshow = (slideshow: CCSlideShowShape) => {
if (presentationMode && presentationService) {
presentationService.zoomToShape(slideshow)
}
}
const renderSlideshow = (slideshow: CCSlideShowShape) => {
const bindings = editor
.getBindingsFromShape<CCSlideLayoutBinding>(slideshow, 'cc-slide-layout')
.filter(b => !b.props.placeholder)
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
return (
<div key={slideshow.id} className="slideshow-container">
<div className="slideshow-header">
<div className="slideshow-title">
<h3>{slideshow.props.title || 'Untitled Slideshow'}</h3>
<span className="slide-count">
{bindings.length} slides
</span>
</div>
{presentationMode && (
<TldrawUiButton
type="icon"
className="zoom-button"
data-testid="zoom-to-slideshow"
onClick={() => handleZoomToSlideshow(slideshow)}
title="Zoom to slideshow"
>
🔍
</TldrawUiButton>
)}
</div>
<div className="slides-list">
{bindings.map((binding, index) => {
const slide = editor.getShape(binding.toId) as CCSlideShape
if (!slide) return null
const isCurrentSlide = currentSlide?.id === slide.id
return (
<div
key={slide.id}
className={`slide-item ${isCurrentSlide ? 'selected' : ''}`}
onClick={() => handleSlideClick(slide)}
>
<span className="slide-number">{index + 1}</span>
<span className="slide-title">
{getSlideLabel(slide, index)}
</span>
</div>
)
})}
</div>
</div>
)
}
return (
<div className="panel-container">
<div className="slides-panel-tools">
<TldrawUiButton
type="normal"
className="shape-button"
data-active={presentationMode}
data-testid="toggle-presentation"
onClick={handleTogglePresentation}
>
{presentationMode ? 'Exit Presentation' : 'Present'}
</TldrawUiButton>
</div>
{slideshows.length === 0 ? (
<div className="panel-empty-state">
<p>No slideshows yet</p>
<p>Create a slideshow to get started</p>
</div>
) : (
slideshows.map(renderSlideshow)
)}
</div>
)
}
@@ -0,0 +1,35 @@
import React from 'react';
import { useEditor } from '@tldraw/tldraw';
import { CC_SHAPE_CONFIGS } from '../../../cc-base/cc-configs';
import { createYoutubeShapeAtCenter } from '../../../cc-base/shape-helpers/youtube-helpers';
import './panel.css';
export const CCYoutubePanel: React.FC = () => {
const editor = useEditor();
const [videoUrl, setVideoUrl] = React.useState<string>(CC_SHAPE_CONFIGS['cc-youtube-embed'].defaultProps.video_url as string);
return (
<div className="panel-container">
<div className="panel-section">
<input
type="text"
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
onPaste={(e) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
setVideoUrl(pastedText);
}}
placeholder="Enter YouTube URL"
className="panel-input"
/>
<button
onClick={() => editor && createYoutubeShapeAtCenter(editor, videoUrl)}
className="shape-button"
>
Add YouTube Video
</button>
</div>
</div>
);
};
@@ -0,0 +1,264 @@
import React, { useCallback, useMemo } from 'react';
import { useEditor, TLPageId, useValue } from '@tldraw/tldraw';
import {
Box,
Typography,
List,
ListItem,
ListItemText,
ListItemIcon,
IconButton,
Menu,
MenuItem,
styled,
ThemeProvider,
createTheme,
useMediaQuery
} from '@mui/material';
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
import {
FileCopy as PageIcon,
Add as AddIcon,
MoreVert as MoreVertIcon,
Delete as DeleteIcon,
Edit as EditIcon,
FileCopy as FileCopyIcon
} from '@mui/icons-material';
const PageSection = styled(Box)(() => ({
display: 'flex',
flexDirection: 'column',
gap: '8px',
color: 'var(--color-text)',
}));
const PageListItem = styled(ListItem, {
shouldForwardProp: (prop) => prop !== 'isSelected',
})<{ isSelected?: boolean }>(({ isSelected }) => ({
borderRadius: '4px',
backgroundColor: isSelected ? 'var(--color-selected-background)' : 'transparent',
transition: 'background-color 200ms ease, transform 200ms ease, box-shadow 200ms ease',
'&:hover': {
backgroundColor: isSelected ? 'var(--color-selected-hover)' : 'var(--color-hover)',
transform: 'translateX(4px)',
'& .MuiListItemIcon-root': {
color: 'var(--color-selected)',
transform: 'scale(1.1)',
},
'& .MuiIconButton-root': {
opacity: 1,
transform: 'scale(1)',
},
},
cursor: 'pointer',
'& .MuiListItemIcon-root': {
color: isSelected ? 'var(--color-selected)' : 'var(--color-text)',
transition: 'color 200ms ease, transform 200ms ease',
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
},
},
'& .MuiIconButton-root': {
opacity: 0,
transform: 'scale(0.8)',
transition: 'opacity 200ms ease, transform 200ms ease, background-color 200ms ease',
'&:hover': {
backgroundColor: 'var(--color-hover)',
transform: 'scale(1.1)',
},
},
}));
const StyledIconButton = styled(IconButton)(() => ({
color: 'var(--color-text)',
transition: 'background-color 200ms ease, transform 200ms ease, color 200ms ease',
'&:hover': {
color: 'var(--color-selected)',
backgroundColor: 'var(--color-hover)',
transform: 'scale(1.1)',
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
},
}));
const StyledMenuItem = styled(MenuItem)(() => ({
gap: '8px',
transition: 'background-color 200ms ease, color 200ms ease',
'&:hover': {
backgroundColor: 'var(--color-hover)',
'& .MuiListItemIcon-root': {
color: 'var(--color-selected)',
transform: 'scale(1.1)',
},
},
'& .MuiListItemIcon-root': {
color: 'var(--color-text)',
minWidth: '32px',
transition: 'color 200ms ease, transform 200ms ease',
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
},
},
'&.Mui-disabled': {
'& .MuiListItemIcon-root': {
color: 'var(--color-text-disabled)',
},
},
}));
export const PageComponent = () => {
const editor = useEditor();
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [menuAnchor, setMenuAnchor] = React.useState<null | { element: HTMLElement; pageId: TLPageId }>(null);
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
// Determine mode based on TLDraw preferences
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: {
mode,
divider: 'var(--color-divider)',
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
// Subscribe to page changes using useValue
const pages = useValue('pages', () => editor.getPages(), [editor]);
const currentPageId = useValue('currentPageId', () => editor.getCurrentPageId(), [editor]);
const handlePageSelect = useCallback((pageId: TLPageId) => {
editor.setCurrentPage(pageId);
}, [editor]);
const handleCreatePage = useCallback(() => {
editor.createPage({
name: `Page ${editor.getPages().length + 1}`
});
}, [editor]);
const handlePageMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>, pageId: TLPageId) => {
event.stopPropagation();
setMenuAnchor({ element: event.currentTarget, pageId });
}, []);
const handlePageMenuClose = useCallback(() => {
setMenuAnchor(null);
}, []);
const handleRenamePage = useCallback(() => {
if (!menuAnchor) return;
const page = editor.getPage(menuAnchor.pageId);
if (!page) return;
const newName = window.prompt('Enter new page name:', page.name);
if (newName) {
editor.renamePage(menuAnchor.pageId, newName);
}
handlePageMenuClose();
}, [editor, menuAnchor]);
const handleDuplicatePage = useCallback(() => {
if (!menuAnchor) return;
editor.duplicatePage(menuAnchor.pageId);
handlePageMenuClose();
}, [editor, menuAnchor]);
const handleDeletePage = useCallback(() => {
if (!menuAnchor) return;
if (pages.length <= 1) {
alert('Cannot delete the last page');
return;
}
editor.deletePage(menuAnchor.pageId);
handlePageMenuClose();
}, [editor, menuAnchor, pages.length]);
return (
<ThemeProvider theme={theme}>
<PageSection>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" sx={{ color: 'var(--color-text-secondary)' }}>Pages</Typography>
<StyledIconButton size="small" onClick={handleCreatePage}>
<AddIcon fontSize="small" />
</StyledIconButton>
</Box>
<List dense>
{pages.map((page) => (
<PageListItem
key={page.id}
isSelected={page.id === currentPageId}
onClick={() => handlePageSelect(page.id)}
>
<ListItemIcon>
<PageIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={page.name}
primaryTypographyProps={{
variant: 'body2',
noWrap: true,
sx: { color: 'var(--color-text)' }
}}
/>
<StyledIconButton
size="small"
onClick={(e) => handlePageMenuOpen(e, page.id)}
>
<MoreVertIcon fontSize="small" />
</StyledIconButton>
</PageListItem>
))}
</List>
<Menu
anchorEl={menuAnchor?.element}
open={Boolean(menuAnchor)}
onClose={handlePageMenuClose}
PaperProps={{
elevation: 8,
sx: {
border: '1px solid var(--color-divider)',
boxShadow: 'var(--shadow-popup)',
},
}}
>
<StyledMenuItem onClick={handleRenamePage}>
<ListItemIcon>
<EditIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Rename</ListItemText>
</StyledMenuItem>
<StyledMenuItem onClick={handleDuplicatePage}>
<ListItemIcon>
<FileCopyIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Duplicate</ListItemText>
</StyledMenuItem>
<StyledMenuItem
onClick={handleDeletePage}
disabled={pages.length <= 1}
>
<ListItemIcon>
<DeleteIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</StyledMenuItem>
</Menu>
</PageSection>
</ThemeProvider>
);
};
@@ -0,0 +1,430 @@
import React, { useEffect, useMemo } from 'react';
import { Box, Typography, ListItemText, ListItemIcon, styled, Select, MenuItem, FormControl, InputLabel, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
import {
AccountCircle as AccountCircleIcon,
CalendarToday as CalendarIcon,
School as TeachingIcon,
Business as BusinessIcon,
AccountTree as DepartmentIcon,
Class as ClassIcon,
Dashboard as DashboardIcon,
Settings as SettingsIcon,
History as HistoryIcon,
Book as JournalIcon,
Event as PlannerIcon,
ViewDay as DayIcon,
ViewWeek as WeekIcon,
ViewModule as MonthIcon,
ViewAgenda as YearIcon,
Schedule as TimetableIcon,
Group as StaffIcon,
School as TeachersIcon,
MenuBook as SubjectsIcon,
} from '@mui/icons-material';
import {
BaseContext,
ExtendedContext,
ViewContext,
ViewDefinition,
CalendarExtendedContext,
TeacherExtendedContext
} from '../../../../../../types/navigation';
import { NAVIGATION_CONTEXTS } from '../../../../../../config/navigationContexts';
import { useNavigationStore } from '../../../../../../stores/navigationStore';
import { useNeoUser } from '../../../../../../contexts/NeoUserContext';
import { CalendarNavigation } from '../../../../../../components/navigation/extended/CalendarNavigation';
import { TeacherNavigation } from '../../../../../../components/navigation/extended/TeacherNavigation';
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
import { logger } from '../../../../../../debugConfig';
const PanelContainer = styled(Box)(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
padding: '16px',
gap: '16px',
overflow: 'auto',
color: 'var(--color-text)',
}));
const menuProps = {
PaperProps: {
elevation: 8,
sx: {
border: '1px solid var(--color-divider)',
boxShadow: 'var(--shadow-popup)',
},
},
};
interface CCNavigationPanelProps {
currentContext: BaseContext;
onContextChange: (context: BaseContext) => void;
currentExtendedContext?: ViewContext;
onExtendedContextChange?: (context: ViewContext) => void;
}
export const CCNavigationPanel: React.FC<CCNavigationPanelProps> = ({
currentContext,
onContextChange,
currentExtendedContext,
onExtendedContextChange,
}) => {
const {
context: navigationContext,
setBaseContext,
setExtendedContext,
isLoading,
error,
} = useNavigationStore();
const { userDbName, workerDbName } = useNeoUser();
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
// Determine mode based on TLDraw preferences
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: {
mode,
divider: 'var(--color-divider)',
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
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 isValidExtendedContextForBase = (extendedContext: ExtendedContext, baseContext: BaseContext): boolean => {
const contextDef = NAVIGATION_CONTEXTS[baseContext];
return contextDef?.views?.some(view => view.id === extendedContext) ?? false;
};
// Sync with navigation store's main context
useEffect(() => {
if (navigationContext.main !== 'profile' && navigationContext.main !== 'institute') {
return;
}
const contextDef = NAVIGATION_CONTEXTS[navigationContext.base];
if (!contextDef) return;
// Update local state to match navigation store
if (navigationContext.base !== currentContext) {
onContextChange(navigationContext.base);
// When base context changes, set appropriate default view
const defaultView = getDefaultViewForContext(navigationContext.base);
if (isValidViewContext(defaultView)) {
onExtendedContextChange?.(defaultView);
}
}
// If current extended context is not valid for this base context, reset to default
if (currentExtendedContext && !isValidExtendedContextForBase(currentExtendedContext, navigationContext.base)) {
const defaultView = getDefaultViewForContext(navigationContext.base);
if (isValidViewContext(defaultView)) {
onExtendedContextChange?.(defaultView);
}
}
}, [navigationContext.main, navigationContext.base, currentContext, currentExtendedContext, onContextChange, onExtendedContextChange]);
const handleContextChange = async (newContext: BaseContext) => {
logger.debug('navigation-panel', '🔄 Starting context change', {
from: currentContext,
to: newContext
});
try {
// Get default view for new context
const defaultView = getDefaultViewForContext(newContext);
logger.debug('navigation-panel', '📍 Determined default view', {
context: newContext,
defaultView
});
// Use unified context switch with both base and extended contexts
const contextUpdate = {
base: newContext,
extended: isValidViewContext(defaultView) ? defaultView : undefined
};
logger.debug('navigation-panel', '🚀 Initiating context switch', contextUpdate);
await setBaseContext(newContext, userDbName, workerDbName);
logger.debug('navigation-panel', '✅ Context switch successful', {
context: newContext,
view: defaultView
});
// Update local state
onContextChange(newContext);
if (isValidViewContext(defaultView)) {
await setExtendedContext(defaultView, userDbName, workerDbName);
onExtendedContextChange?.(defaultView);
}
} catch (error) {
logger.error('navigation-panel', '❌ Failed to change context', {
error,
attemptedContext: newContext
});
console.error('Failed to change context:', error);
}
};
const handleExtendedContextChange = async (newContext: ViewContext) => {
logger.debug('navigation-panel', '🔄 Starting extended context change', {
from: currentExtendedContext,
to: newContext
});
try {
// Validate that the new context is valid for current base context
if (!isValidExtendedContextForBase(newContext, currentContext)) {
logger.warn('navigation-panel', '⚠️ Invalid extended context combination', {
baseContext: currentContext,
attemptedExtendedContext: newContext
});
return;
}
const contextUpdate = { extended: newContext };
logger.debug('navigation-panel', '🚀 Initiating extended context switch', contextUpdate);
// Use unified context switch for extended context only
await setExtendedContext(newContext, userDbName, workerDbName);
logger.debug('navigation-panel', '✅ Extended context switch successful', {
newView: newContext
});
// Update local state
onExtendedContextChange?.(newContext);
} catch (error) {
logger.error('navigation-panel', '❌ Failed to change extended context', {
error,
attemptedContext: newContext
});
console.error('Failed to change extended context:', error);
}
};
// Add helper function to validate ViewContext
const isValidViewContext = (context: ExtendedContext): context is ViewContext => {
const validViewContexts: ViewContext[] = [
// Common views
'overview',
// User views
'settings', 'history', 'journal', 'planner',
// Calendar views
'day', 'week', 'month', 'year',
// Teaching views
'timetable', 'classes', 'lessons',
// School views
'departments', 'staff',
// Department views
'teachers', 'subjects',
// Class views
'students'
];
return validViewContexts.includes(context as ViewContext);
};
const getContextIcon = (contextType: string) => {
const iconProps = {
sx: {
color: 'var(--color-text)',
}
};
switch (contextType) {
case 'profile':
return <AccountCircleIcon {...iconProps} />;
case 'calendar':
return <CalendarIcon {...iconProps} />;
case 'teaching':
return <TeachingIcon {...iconProps} />;
case 'school':
return <BusinessIcon {...iconProps} />;
case 'department':
return <DepartmentIcon {...iconProps} />;
case 'class':
return <ClassIcon {...iconProps} />;
case 'overview':
return <DashboardIcon {...iconProps} />;
case 'settings':
return <SettingsIcon {...iconProps} />;
case 'history':
return <HistoryIcon {...iconProps} />;
case 'journal':
return <JournalIcon {...iconProps} />;
case 'planner':
return <PlannerIcon {...iconProps} />;
case 'day':
return <DayIcon {...iconProps} />;
case 'week':
return <WeekIcon {...iconProps} />;
case 'month':
return <MonthIcon {...iconProps} />;
case 'year':
return <YearIcon {...iconProps} />;
case 'timetable':
return <TimetableIcon {...iconProps} />;
case 'staff':
return <StaffIcon {...iconProps} />;
case 'teachers':
return <TeachersIcon {...iconProps} />;
case 'subjects':
return <SubjectsIcon {...iconProps} />;
default:
return <AccountCircleIcon {...iconProps} />;
}
};
const renderContextDropdown = () => {
const items = navigationContext.main === 'profile' ? [
{ id: 'profile', label: 'Profile' },
{ id: 'calendar', label: 'Calendar' },
{ id: 'teaching', label: 'Teaching' }
] : [
{ id: 'school', label: 'School' },
{ id: 'department', label: 'Department' },
{ id: 'class', label: 'Class' }
];
return (
<ThemeProvider theme={theme}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel>Context</InputLabel>
<Select
value={currentContext}
onChange={(e) => handleContextChange(e.target.value as BaseContext)}
label="Context"
MenuProps={menuProps}
>
{items.map(item => (
<MenuItem key={item.id} value={item.id}>
<ListItemIcon>
{getContextIcon(item.id)}
</ListItemIcon>
<ListItemText primary={item.label} />
</MenuItem>
))}
</Select>
</FormControl>
</ThemeProvider>
);
};
const renderExtendedContextDropdown = () => {
const contextDef = NAVIGATION_CONTEXTS[currentContext];
if (!contextDef?.views?.length) return null;
return (
<ThemeProvider theme={theme}>
<FormControl fullWidth variant="outlined" size="small">
<InputLabel>View</InputLabel>
<Select
value={currentExtendedContext || contextDef.views[0].id}
onChange={(e) => handleExtendedContextChange(e.target.value as ViewContext)}
label="View"
MenuProps={menuProps}
>
{contextDef.views.map((view: ViewDefinition) => (
<MenuItem key={view.id} value={view.id}>
<ListItemIcon>
{getContextIcon(view.id)}
</ListItemIcon>
<ListItemText
primary={view.label}
secondary={view.description}
/>
</MenuItem>
))}
</Select>
</FormControl>
</ThemeProvider>
);
};
const renderContextSpecificNavigation = () => {
if (!currentExtendedContext) return null;
switch (currentContext) {
case 'calendar':
return (
<CalendarNavigation
activeView={currentExtendedContext as CalendarExtendedContext}
onViewChange={(view) => handleExtendedContextChange(view)}
/>
);
case 'teaching':
return (
<TeacherNavigation
activeView={currentExtendedContext as TeacherExtendedContext}
onViewChange={(view) => handleExtendedContextChange(view)}
/>
);
default:
return null;
}
};
return (
<ThemeProvider theme={theme}>
<PanelContainer>
{renderContextDropdown()}
{renderExtendedContextDropdown()}
{renderContextSpecificNavigation()}
{error && (
<Typography
variant="body2"
sx={{
mt: 2,
color: 'var(--color-error)'
}}
>
{error}
</Typography>
)}
{isLoading && (
<Typography
variant="body2"
sx={{
mt: 2,
color: 'var(--color-text-secondary)'
}}
>
Loading...
</Typography>
)}
</PanelContainer>
</ThemeProvider>
);
};
@@ -0,0 +1,230 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Box, Typography, styled, Button, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
import { Save as SaveIcon, RestartAlt as ResetIcon } from '@mui/icons-material';
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
import { useNavigationStore } from '../../../../../../stores/navigationStore';
import { UserNeoDBService } from '../../../../../../services/graph/userNeoDBService';
import { PageComponent } from '../components/pageComponent';
import { logger } from '../../../../../../debugConfig';
import { useTLDraw } from '../../../../../../contexts/TLDrawContext';
import { NavigationSnapshotService } from '../../../../../../services/tldraw/snapshotService';
import { blankCanvasSnapshot } from '../../../../../tldraw/assets';
const CurrentNodeSection = styled(Box)(() => ({
padding: '8px',
backgroundColor: 'var(--color-panel)',
borderRadius: '4px',
marginBottom: '8px',
'&:hover': {
backgroundColor: 'var(--color-hover)',
}
}));
const NodeInfoContainer = styled(Box)(() => ({
display: 'flex',
flexDirection: 'column',
gap: '4px',
marginBottom: '12px'
}));
const ActionButton = styled(Button)(() => ({
textTransform: 'none',
padding: '6px 16px',
gap: '8px',
backgroundColor: 'var(--color-panel)',
color: 'var(--color-text)',
border: '1px solid transparent',
transition: 'border-color 200ms ease',
'&:hover': {
backgroundColor: 'var(--color-panel)',
borderColor: 'var(--color-text)',
},
'&:active': {
backgroundColor: 'var(--color-panel)',
},
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
color: 'inherit',
transition: 'transform 200ms ease',
},
'&:hover .MuiSvgIcon-root': {
transform: 'scale(1.1) rotate(-10deg)',
},
'&.Mui-disabled': {
backgroundColor: 'var(--color-muted)',
color: 'var(--color-text-disabled)',
borderColor: 'transparent',
}
}));
const ButtonContainer = styled(Box)(() => ({
display: 'flex',
gap: '8px',
width: '100%'
}));
export const CCNodeSnapshotPanel: React.FC = () => {
const editor = useEditor();
const { addToast } = useToasts();
const { context: navigationContext, isLoading, error } = useNavigationStore();
const { tldrawPreferences } = useTLDraw();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [isSaving, setIsSaving] = useState(false);
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
// Determine mode based on TLDraw preferences
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: {
mode,
divider: 'var(--color-divider)',
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const handleResetCanvas = useCallback(() => {
try {
loadSnapshot(editor.store, blankCanvasSnapshot);
addToast({
title: 'Canvas reset',
description: 'The canvas has been reset to blank.',
icon: 'reset-zoom',
});
} catch (error) {
logger.error('cc-node-snapshot-panel', '❌ Failed to reset canvas', {
error: error instanceof Error ? error.message : 'Unknown error'
});
addToast({
title: 'Error',
description: 'Failed to reset canvas',
icon: 'warning-triangle',
});
}
}, [editor.store, addToast]);
const handleSaveSnapshot = useCallback(async () => {
if (isSaving) return;
try {
setIsSaving(true);
if (!navigationContext.node?.id) {
logger.error('cc-node-snapshot-panel', '❌ No current node available for saving');
addToast({
title: 'Error',
description: 'No current node available for saving',
icon: 'warning-triangle',
});
return;
}
logger.info('cc-node-snapshot-panel', '💾 Saving snapshot', {
id: navigationContext.node.id,
type: navigationContext.node.type
});
const dbName = UserNeoDBService.getNodeDatabaseName(navigationContext.node);
await NavigationSnapshotService.saveNodeSnapshotToDatabase(navigationContext.node.id, dbName, editor.store);
addToast({
title: 'Snapshot saved',
description: 'Your snapshot has been saved successfully.',
icon: 'check',
});
} catch (error) {
logger.error('cc-node-snapshot-panel', '❌ Failed to save snapshot', {
error: error instanceof Error ? error.message : 'Unknown error'
});
addToast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save snapshot',
icon: 'warning-triangle',
});
} finally {
setIsSaving(false);
}
}, [editor, navigationContext.node, addToast, isSaving]);
const renderCurrentNode = () => {
if (!navigationContext.node) return null;
return (
<CurrentNodeSection>
<NodeInfoContainer>
<Typography variant="subtitle2" sx={{ color: 'var(--color-text-secondary)' }}>
Current Node
</Typography>
<Typography variant="body1" sx={{ color: 'var(--color-text)' }}>
{navigationContext.node.label || navigationContext.node.id}
</Typography>
<Typography variant="caption" sx={{ color: 'var(--color-text-secondary)' }}>
{navigationContext.node.type}
</Typography>
</NodeInfoContainer>
<ButtonContainer>
<ActionButton
variant="contained"
size="small"
startIcon={<SaveIcon />}
onClick={handleSaveSnapshot}
disabled={isLoading}
sx={{ flex: 1 }}
>
Save Snapshot
</ActionButton>
<ActionButton
variant="contained"
size="small"
startIcon={<ResetIcon />}
onClick={handleResetCanvas}
disabled={isLoading}
sx={{ flex: 1 }}
>
Reset Canvas
</ActionButton>
</ButtonContainer>
</CurrentNodeSection>
);
};
return (
<ThemeProvider theme={theme}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{renderCurrentNode()}
<PageComponent />
{error && (
<Typography
variant="body2"
sx={{
mt: 2,
color: 'var(--color-error)'
}}
>
{error}
</Typography>
)}
{isLoading && (
<Typography
variant="body2"
sx={{
mt: 2,
color: 'var(--color-text-secondary)'
}}
>
Loading...
</Typography>
)}
</Box>
</ThemeProvider>
);
};
@@ -0,0 +1,48 @@
export const PANEL_DIMENSIONS = {
'navigation': {
width: '300px',
topOffset: `0px`,
bottomOffset: '0px',
},
'node-snapshot': {
width: '300px',
topOffset: `0px`,
bottomOffset: '0px',
},
'cc-shapes': {
width: '300px',
topOffset: `0px`,
bottomOffset: '0px',
},
'slides': {
width: '300px',
topOffset: `0px`,
bottomOffset: '0px',
},
'youtube': {
width: '300px',
topOffset: `0px`,
bottomOffset: '0px',
},
'graph': {
width: '300px',
topOffset: `0px`,
bottomOffset: '0px',
},
'exam-marker': {
width: '300px',
topOffset: `0px`,
bottomOffset: '0px',
},
'search': {
width: '300px',
topOffset: `0px`,
bottomOffset: '0px',
},
} as const;
// Z-index constants for panel layering
export const Z_INDICES = {
HANDLE: 999,
PANEL: 1000,
} as const;
@@ -0,0 +1,271 @@
/* Base Panel Layout */
.panel-root {
position: absolute;
left: 0;
background: var(--color-panel);
border-right: 1px solid var(--color-divider);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-1);
}
.panel-handle {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 48px;
background: var(--color-panel);
border: 1px solid var(--color-divider);
border-left: none;
border-radius: 0 4px 4px 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 20px;
color: var(--color-text);
z-index: var(--layer-panels);
box-shadow: var(--shadow-1);
}
.panel-handle:hover {
background: var(--color-hover);
}
.panel-header {
padding: 8px;
border-bottom: 1px solid var(--color-divider);
display: flex;
align-items: center;
gap: 8px;
}
.panel-header-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.panel-type-select {
flex: 1;
min-width: 0;
padding: 4px 8px;
border-radius: 4px;
background: var(--color-background);
border: 1px solid var(--color-divider);
color: var(--color-text);
font: inherit;
}
.panel-content {
flex: 1;
height: 100%;
overflow-y: auto;
padding: 8px;
-ms-overflow-style: none;
scrollbar-width: none;
}
.panel-content::-webkit-scrollbar {
display: none;
}
.pin-button {
color: var(--color-text-2);
}
.pin-button:hover {
color: var(--color-text);
}
.pin-button.pinned {
color: var(--color-primary);
}
/* Common Panel Styles */
.panel-container {
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
overflow-y: auto;
-ms-overflow-style: none; /* Hide scrollbar for IE and Edge */
scrollbar-width: none; /* Hide scrollbar for Firefox */
}
/* Hide scrollbar for Chrome, Safari and Opera */
.panel-container::-webkit-scrollbar {
display: none;
}
/* Input Styles */
.panel-input {
padding: 8px;
border-radius: 4px;
border: 1px solid var(--color-muted-1);
font-size: 14px;
background-color: var(--color-background);
color: var(--color-text);
width: 100%;
transition: border-color 0.2s ease;
}
.panel-input:focus {
outline: none;
border-color: var(--color-selected);
}
.panel-input::placeholder {
color: var(--color-text-2);
}
/* Button Styles */
.shape-button {
padding: 8px 12px;
width: 100%;
text-align: left;
background-color: var(--color-panel);
border: 1px solid var(--color-divider);
border-radius: 4px;
color: var(--color-text);
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s ease;
}
.shape-button:hover {
background-color: var(--color-hover);
}
/* Section Styles */
.panel-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-section-title {
font-size: 14px;
color: var(--color-text);
margin-bottom: 4px;
}
.panel-divider {
border-top: 1px solid var(--color-divider);
margin: 8px 0;
}
/* Dropdown/Flyout Menu */
.panel-dropdown {
position: absolute;
left: 100%;
top: 0;
background-color: var(--color-panel);
border: 1px solid var(--color-divider);
border-radius: 4px;
padding: 4px;
z-index: 1000;
max-height: 400px;
overflow-y: auto;
width: 200px;
}
/* Slides Panel Specific */
.slides-panel-tools {
flex-shrink: 0; /* Prevent tools from shrinking */
display: flex;
justify-content: flex-start;
padding: 0 0 8px 0;
border-bottom: 1px solid var(--color-divider);
}
.slideshow-container {
border: 1px solid var(--color-divider);
border-radius: 4px;
overflow: visible;
margin-bottom: 16px;
display: flex;
flex-direction: column;
flex-shrink: 0; /* Prevent container from shrinking */
}
.slideshow-header {
background: var(--color-muted);
padding: 8px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--color-divider);
}
.slideshow-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.slideshow-title h3 {
margin: 0;
font-size: 14px;
font-weight: 500;
}
.slide-count {
font-size: 12px;
color: var(--color-text-2);
}
.slides-list {
display: flex;
flex-direction: column;
flex: 1;
}
.slide-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
cursor: pointer;
border-bottom: 1px solid var(--color-divider);
transition: background-color 0.2s;
min-height: 36px;
}
.slide-item:last-child {
border-bottom: none;
}
.slide-number {
min-width: 20px;
font-size: 12px;
color: var(--color-text-2);
}
.slide-title {
flex: 1;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.slide-item:hover {
background-color: var(--color-hover);
}
.slide-item.selected {
background-color: var(--color-selected);
color: var(--color-selected-contrast);
}
/* Empty State */
.panel-empty-state {
text-align: center;
color: var(--color-text-2);
padding: 16px;
}
@@ -0,0 +1,83 @@
import React from 'react';
import { Select, MenuItem, SelectChangeEvent } from '@mui/material';
import { ViewColumn, ViewList, ViewModule, ViewQuilt, ViewStream } from '@mui/icons-material';
import '../panel.css'; // Import the CSS file from parent directory
// Add panel type icons mapping
const PANEL_TYPE_ICONS = {
'default': <ViewQuilt />,
'list': <ViewList />,
'grid': <ViewModule />,
'column': <ViewColumn />,
'stream': <ViewStream />
};
interface BasePanelProps {
title?: string;
showTypeSelector?: boolean;
type?: keyof typeof PANEL_TYPE_ICONS;
handleTypeChange?: (event: SelectChangeEvent) => void;
children?: React.ReactNode;
}
export const BasePanel: React.FC<BasePanelProps> = ({
title,
showTypeSelector = false,
type = 'default',
handleTypeChange,
children
}) => {
return (
<div className="panel-root">
<div className="panel-header">
{title && <div className="panel-section-title">{title}</div>}
<div className="panel-header-actions">
{showTypeSelector && (
<Select
value={type}
onChange={handleTypeChange}
size="small"
className="panel-type-select"
sx={{
'.MuiSelect-select': {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 8px',
},
'.MuiOutlinedInput-notchedOutline': {
border: 'none'
},
'&:hover .MuiOutlinedInput-notchedOutline': {
border: 'none'
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
border: 'none'
}
}}
>
{Object.entries(PANEL_TYPE_ICONS).map(([value, icon]) => (
<MenuItem
key={value}
value={value}
sx={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{icon}
{value.charAt(0).toUpperCase() + value.slice(1)}
</MenuItem>
))}
</Select>
)}
</div>
</div>
<div className="panel-content">
{children}
</div>
</div>
);
};
// ... existing code ...