Initial commit
This commit is contained in:
@@ -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 ...
|
||||
Reference in New Issue
Block a user