- New examRepository (R2.1 seam): the only module talking to /api/exam (Supabase JWT → Bearer, axios to API_BASE). list/get/create/archive templates. - ExamDashboardPage (/exam-marker): lists institute templates, create dialog, archive; cards link to setup (S4-9). Wrapped in ErrorBoundary (R6.4). - exam.types.ts mirrors the API contract. - Dashboard 'Exam Marker' quick action (top-level discovery, R1.3/R6.1). Note: the in-canvas TeacherNavigation is unsuitable for a nav section (it's the worker prev/next tab bar) — flagged; used the dashboard entry instead. - R1.1 removal: deleted src/pages/tldraw/CCExamMarker/ (old 3-PDF viewer) and the now-dead CCExamMarkerPanel + its wiring in CCPanel/BasePanel (examMarkerProps was only ever passed by the deleted page). - Fixed pre-existing broken AppRoutes test (missing TimetableListPage mock export). Build green (vite); AppRoutes route tests pass; typecheck clean for new files. Co-Authored-By: Claude Opus 4.8 <[email protected]>
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
import React, { useEffect, useRef, useMemo, useState } from 'react';
|
||
import { TldrawUiButton } from '@tldraw/tldraw';
|
||
import { CCShapesPanel } from './CCShapesPanel';
|
||
import { CCSlidesPanel } from './CCSlidesPanel';
|
||
import { CCFilesPanel } from './CCFilesPanel';
|
||
import { CCCabinetsPanel } from './CCCabinetsPanel';
|
||
import { CCYoutubePanel } from './CCYoutubePanel';
|
||
import { CCGraphPanel } from './CCGraphPanel';
|
||
import { CCSearchPanel } from './CCSearchPanel';
|
||
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
|
||
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
|
||
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: 'cabinets', label: 'Cabinets', order: 5 },
|
||
{ id: 'navigation', label: 'Navigation', order: 10 },
|
||
{ id: 'node-snapshot', label: 'Node', order: 20 },
|
||
{ id: 'files', label: 'Files', order: 25 },
|
||
{ id: 'cc-shapes', label: 'Shapes', order: 30 },
|
||
{ id: 'transcription', label: 'Transcription', order: 35 },
|
||
{ id: 'slides', label: 'Slides', order: 40 },
|
||
{ id: 'youtube', label: 'YouTube', order: 50 },
|
||
{ id: 'graph', label: 'Graph', order: 60 },
|
||
{ id: 'search', label: 'Search', order: 70 },
|
||
],
|
||
} as const;
|
||
|
||
export type PanelType = typeof PANEL_TYPES.default[number]['id'];
|
||
|
||
interface BasePanelProps {
|
||
initialPanelType?: PanelType;
|
||
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;
|
||
}
|
||
|
||
function getIconSvg(panelId: PanelType, size = '1.25rem') {
|
||
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
|
||
switch (panelId) {
|
||
case 'cabinets':
|
||
return (
|
||
<svg {...common}>
|
||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||
<polyline points="9 22 9 12 15 12 15 22" />
|
||
</svg>
|
||
);
|
||
case 'transcription':
|
||
return (
|
||
<svg {...common}>
|
||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||
<line x1="12" y1="19" x2="12" y2="23" />
|
||
<line x1="8" y1="23" x2="6" y2="21" />
|
||
<line x1="16" y1="23" x2="14" y2="21" />
|
||
</svg>
|
||
);
|
||
case 'cc-shapes':
|
||
return (
|
||
<svg {...common}>
|
||
<rect x="3" y="3" width="7" height="7" />
|
||
<rect x="14" y="3" width="7" height="7" />
|
||
<rect x="14" y="14" width="7" height="7" />
|
||
<rect x="3" y="14" width="7" height="7" />
|
||
</svg>
|
||
);
|
||
case 'slides':
|
||
return (
|
||
<svg {...common}>
|
||
<rect x="2" y="3" width="13" height="18" rx="2" />
|
||
<path d="M15 8h4l3 3v7h-7V8z" />
|
||
</svg>
|
||
);
|
||
case 'youtube':
|
||
return (
|
||
<svg {...common}>
|
||
<rect x="2" y="4" width="20" height="16" rx="4" />
|
||
<polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none" />
|
||
</svg>
|
||
);
|
||
case 'graph':
|
||
return (
|
||
<svg {...common}>
|
||
<circle cx="18" cy="5" r="3" />
|
||
<circle cx="6" cy="12" r="3" />
|
||
<circle cx="18" cy="19" r="3" />
|
||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||
</svg>
|
||
);
|
||
case 'search':
|
||
return (
|
||
<svg {...common}>
|
||
<circle cx="11" cy="11" r="8" />
|
||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||
</svg>
|
||
);
|
||
case 'navigation':
|
||
return (
|
||
<svg {...common}>
|
||
<polygon points="3 11 22 2 13 21 11 13 3 11" />
|
||
</svg>
|
||
);
|
||
case 'node-snapshot':
|
||
return (
|
||
<svg {...common}>
|
||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||
</svg>
|
||
);
|
||
default:
|
||
return (
|
||
<svg {...common}>
|
||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||
</svg>
|
||
);
|
||
}
|
||
}
|
||
|
||
function isDarkMode() {
|
||
if (typeof window === 'undefined') return false;
|
||
return document.documentElement.dataset.colorMode === 'dark' ||
|
||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
}
|
||
|
||
export const BasePanel: React.FC<BasePanelProps> = ({
|
||
initialPanelType = 'files',
|
||
isExpanded: controlledIsExpanded,
|
||
isPinned: controlledIsPinned,
|
||
onExpandedChange,
|
||
onPinnedChange,
|
||
isMenuOpen = false,
|
||
onMenuOpenChange = () => {},
|
||
}) => {
|
||
const { tldrawPreferences } = useTLDraw();
|
||
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||
const handler = () => setPrefersDarkMode(isDarkMode());
|
||
mq.addEventListener?.('change', handler);
|
||
return () => mq.removeEventListener?.('change', handler);
|
||
}, []);
|
||
|
||
const colorMode = (tldrawPreferences?.colorScheme === 'system' ? prefersDarkMode : tldrawPreferences?.colorScheme === 'dark')
|
||
? 'dark'
|
||
: 'light';
|
||
|
||
const availablePanels = PANEL_TYPES.default;
|
||
|
||
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
|
||
|
||
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
||
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
||
|
||
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];
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (isPinned) return;
|
||
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
|
||
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 renderCurrentPanel = () => {
|
||
switch (currentPanelType) {
|
||
case 'cabinets':
|
||
return <CCCabinetsPanel />;
|
||
case 'transcription':
|
||
return <CCTranscriptionPanel />;
|
||
case 'files':
|
||
return <CCFilesPanel />;
|
||
case 'cc-shapes':
|
||
return <CCShapesPanel />;
|
||
case 'slides':
|
||
return <CCSlidesPanel />;
|
||
case 'youtube':
|
||
return <CCYoutubePanel />;
|
||
case 'graph':
|
||
return <CCGraphPanel />;
|
||
case 'search':
|
||
return <CCSearchPanel />;
|
||
case 'navigation':
|
||
return <CCGraphNavPanel />;
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const toggleMenu = (open: boolean) => {
|
||
setMenuOpen(open);
|
||
onMenuOpenChange(open);
|
||
};
|
||
|
||
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">
|
||
<button
|
||
type="button"
|
||
className="cc-btn cc-btn--secondary cc-panel-selector"
|
||
onClick={() => toggleMenu(!menuOpen)}
|
||
>
|
||
<span className="cc-btn-start-icon">
|
||
{getIconSvg(currentPanelType)}
|
||
</span>
|
||
<span className="cc-btn-label">
|
||
{availablePanels.find(p => p.id === currentPanelType)?.label}
|
||
</span>
|
||
<span className="cc-btn-end-icon">
|
||
<svg width="1.25rem" height="1.25rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="6 9 12 15 18 9" />
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
|
||
{menuOpen && (
|
||
<div className="cc-menu panel-type-menu" role="menu">
|
||
{[...availablePanels]
|
||
.sort((a, b) => a.order - b.order)
|
||
.map(type => (
|
||
<button
|
||
key={type.id}
|
||
type="button"
|
||
role="menuitem"
|
||
className={`cc-menu-item ${type.id === currentPanelType ? 'cc-menu-item--active' : ''}`}
|
||
onClick={() => {
|
||
setCurrentPanelType(type.id as PanelType);
|
||
toggleMenu(false);
|
||
}}
|
||
>
|
||
<span className="cc-menu-item-icon">
|
||
{getIconSvg(type.id as PanelType)}
|
||
</span>
|
||
<span className="cc-menu-item-text">
|
||
<span className="cc-menu-item-primary">{type.label}</span>
|
||
<span className="cc-menu-item-secondary">
|
||
{type.id === 'cabinets' && 'Manage file cabinets'}
|
||
{type.id === 'transcription' && 'Record and transcribe lessons'}
|
||
{type.id === 'cc-shapes' && 'Add shapes and elements to your canvas'}
|
||
{type.id === 'slides' && 'Manage presentation slides'}
|
||
{type.id === 'youtube' && 'Embed YouTube videos'}
|
||
{type.id === 'graph' && 'View and manage graph connections'}
|
||
{type.id === 'search' && 'Search through your content'}
|
||
{type.id === 'navigation' && 'Navigate through different contexts'}
|
||
{type.id === 'node-snapshot' && 'Manage node snapshots'}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="panel-header-actions">
|
||
<TldrawUiButton
|
||
type="icon"
|
||
onClick={handlePinToggle}
|
||
className="pin-button"
|
||
>
|
||
<span className="cc-icon-button">
|
||
<svg
|
||
width="1.25rem"
|
||
height="1.25rem"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
{isPinned ? (
|
||
<>
|
||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z" />
|
||
</>
|
||
) : (
|
||
<>
|
||
<circle cx="12" cy="12" r="10" />
|
||
<path d="M7.5 7.5l9 9" />
|
||
</>
|
||
)}
|
||
</svg>
|
||
</span>
|
||
</TldrawUiButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel-content">
|
||
{renderCurrentPanel()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
};
|