Files
app/src/utils/tldraw/ui-overrides/components/shared/BasePanel.tsx
T
CC WorkerandClaude Opus 4.8 29554ebdbd feat(exam): Assessment dashboard + /exam-marker route; remove old CCExamMarker (S4-8)
- 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]>
2026-06-06 19:47:02 +00:00

357 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)}
</>
);
};