Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b459a6ca | ||
|
|
adc7a2a05b | ||
|
|
29554ebdbd | ||
|
|
434643596e | ||
|
|
313ab724b8 | ||
|
|
ee7d7f7cac | ||
|
|
0dd18c4fd4 | ||
|
|
5869c741d7 | ||
|
|
5b6e461706 | ||
|
|
53adc74a1c | ||
|
|
9a7cc86a75 | ||
|
|
6cba932146 | ||
|
|
ba829be8e9 | ||
|
|
9f58917c39 | ||
|
|
a375fbe37f | ||
|
|
38ca23dc3e | ||
|
|
8fe8edb341 | ||
|
|
143fff6414 | ||
|
|
c216df8021 | ||
|
|
f7bb84f9b8 | ||
|
|
cbff4deef2 | ||
|
|
85ecae7147 | ||
|
|
8a79675497 | ||
|
|
6951bc1700 | ||
|
|
e6f6bd5c3a | ||
|
|
b3ec09fbef | ||
|
|
765573163d | ||
|
|
1505adb154 | ||
|
|
ade0be97f6 | ||
|
|
96db086ecb | ||
|
|
6610abbd72 | ||
|
|
489c2208c5 | ||
|
|
2e3cb134d1 | ||
|
|
aca4d045b2 | ||
|
|
a0afbc90f5 | ||
|
|
57429f3a75 | ||
|
|
01a8a59349 | ||
|
|
0150ca3c32 | ||
|
|
33b9278085 | ||
|
|
9196af12f8 | ||
|
|
f6e2ab63dd | ||
|
|
f1c1a72f44 | ||
|
|
1e7b387b43 | ||
|
|
135ba4d26d | ||
|
|
8c1623256b | ||
|
|
b246106876 | ||
|
|
d5a7464e15 | ||
|
|
061cd22748 | ||
|
|
55da04a3f7 | ||
|
|
2a6359e247 | ||
|
|
3afba919c4 | ||
|
|
07ceef1294 | ||
|
|
50c7bb68bb | ||
|
|
3ffbb11552 | ||
|
|
669a6616d8 | ||
|
|
65bce2c52d | ||
|
|
cd37771c52 | ||
|
|
df59207add | ||
|
|
2d15b7cc03 | ||
|
|
d3bd25d544 |
@@ -47,3 +47,6 @@ docker-compose.override.yml
|
||||
# Local environment variants
|
||||
.env.dev
|
||||
.env.prod
|
||||
|
||||
# Playwright test artifacts
|
||||
test-results/
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"react-refresh": "^0.14.2",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^1.6.0"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'postcss-import': {},
|
||||
},
|
||||
|
||||
+51
-10
@@ -1,5 +1,8 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { useTLDraw } from './contexts/TLDrawContext';
|
||||
import { theme } from './services/themeService';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TLDrawProvider } from './contexts/TLDrawContext';
|
||||
@@ -8,21 +11,59 @@ import AppRoutes from './AppRoutes';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import React from 'react';
|
||||
|
||||
const App = React.memo(() => (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
/**
|
||||
* Syncs tldraw's color scheme to data-color-mode on <html>.
|
||||
* Must live inside TLDrawProvider so useTLDraw() reads live preferences.
|
||||
* This activates the [data-color-mode="dark"] overrides in cc-design-system.css.
|
||||
* tldraw itself uses tl-theme__dark class on .tl-container — we mirror that
|
||||
* to the document root so --cc-* token overrides apply globally.
|
||||
*/
|
||||
const DarkModeSync: React.FC = () => {
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
useEffect(() => {
|
||||
const scheme = tldrawPreferences?.colorScheme ?? 'system';
|
||||
const isDark = scheme === 'dark' || (scheme === 'system' && prefersDarkMode);
|
||||
document.documentElement.setAttribute('data-color-mode', isDark ? 'dark' : 'light');
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const App = React.memo(() => {
|
||||
const prefersDarkMode =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const appTheme = useMemo(() => {
|
||||
// App is outside TLDrawProvider so tldrawPreferences is always null here.
|
||||
// Theme defaults to OS preference; DarkModeSync (inside TLDrawProvider) handles
|
||||
// updating data-color-mode once tldraw preferences are loaded.
|
||||
const mode = prefersDarkMode ? 'dark' : 'light';
|
||||
return createTheme({ palette: { mode } });
|
||||
}, [prefersDarkMode]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<AuthProvider>
|
||||
<UserProvider>
|
||||
<TLDrawProvider>
|
||||
<AppRoutes />
|
||||
<DarkModeSync />
|
||||
<ThemeProvider theme={appTheme}>
|
||||
<CssBaseline />
|
||||
<AppRoutes />
|
||||
</ThemeProvider>
|
||||
</TLDrawProvider>
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
));
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
App.displayName = import.meta.env.VITE_APP_NAME;
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
|
||||
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
||||
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
||||
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
||||
vi.mock('./pages/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () => <div>Exam Marker</div> }));
|
||||
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
|
||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||
@@ -46,6 +46,7 @@ vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
|
||||
}));
|
||||
vi.mock('./pages/timetable', () => ({
|
||||
TimetablePage: () => <div>Timetable</div>,
|
||||
TimetableListPage: () => <div>Timetable List</div>,
|
||||
ClassesPage: () => <div>Classes</div>,
|
||||
LessonPage: () => <div>Lesson</div>,
|
||||
TaughtLessonsPage: () => <div>Taught Lessons</div>,
|
||||
|
||||
+34
-7
@@ -7,7 +7,8 @@ import LoginPage from './pages/auth/loginPage';
|
||||
import SignupPage from './pages/auth/signupPage';
|
||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||
import { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
|
||||
import { ExamDashboardPage, ExamTemplateSetupPage } from './pages/exam';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import CalendarPage from './pages/user/calendarPage';
|
||||
import SettingsPage from './pages/user/settingsPage';
|
||||
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
||||
@@ -30,6 +31,7 @@ import DashboardPage from './pages/user/dashboardPage';
|
||||
// Timetable Module Pages
|
||||
import {
|
||||
TimetablePage,
|
||||
TimetableListPage,
|
||||
ClassesPage,
|
||||
LessonPage,
|
||||
TaughtLessonsPage,
|
||||
@@ -45,8 +47,6 @@ import {
|
||||
} from './pages/timetable';
|
||||
|
||||
const FullContextRoutes: React.FC = () => {
|
||||
// Only block on Supabase profile being ready — Neo4j contexts initialize in the background.
|
||||
// Individual pages handle their own Neo4j loading states.
|
||||
const { isInitialized: isUserInitialized } = useUser();
|
||||
|
||||
if (!isUserInitialized) {
|
||||
@@ -68,6 +68,31 @@ const FullContextRoutes: React.FC = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
const RequireAuth: React.FC = () => {
|
||||
const { user, isAuthResolving } = useAuth();
|
||||
|
||||
if (isAuthResolving) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
const AppRoutes: React.FC = () => {
|
||||
const { user, user_role, loading: isAuthLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
@@ -136,10 +161,11 @@ const AppRoutes: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||
{user && (
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<FullContextRoutes />}>
|
||||
{/* Timetable Module Routes */}
|
||||
<Route path="/timetable" element={<TimetablePage />} />
|
||||
<Route path="/timetable" element={<TimetableListPage />} />
|
||||
<Route path="/timetable/:timetableId" element={<TimetablePage />} />
|
||||
<Route path="/classes" element={<ClassesPage />} />
|
||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||
@@ -156,7 +182,8 @@ const AppRoutes: React.FC = () => {
|
||||
{/* Existing Routes */}
|
||||
<Route path="/search" element={<SearxngPage />} />
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<CCExamMarker />} />
|
||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||
<Route path="/morphic" element={<MorphicPage />} />
|
||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||
@@ -167,7 +194,7 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
||||
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AuthContextType {
|
||||
signOut: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
bootstrapData: BootstrapResponse | null;
|
||||
isAuthResolving: boolean;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType>({
|
||||
@@ -29,7 +30,8 @@ export const AuthContext = createContext<AuthContextType>({
|
||||
signIn: async () => {},
|
||||
signOut: async () => {},
|
||||
clearError: () => {},
|
||||
bootstrapData: null
|
||||
bootstrapData: null,
|
||||
isAuthResolving: false
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -40,6 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
||||
const [isAuthResolving, setIsAuthResolving] = useState(false);
|
||||
|
||||
|
||||
const persistSession = useCallback((session: Session | null) => {
|
||||
@@ -92,6 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
persistSession(session ?? null);
|
||||
setIsAuthResolving(!!session?.user);
|
||||
if (session?.user) {
|
||||
try {
|
||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||
@@ -112,11 +116,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setAccessToken(null);
|
||||
}
|
||||
setLoading(false);
|
||||
setIsAuthResolving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
||||
persistSession(session ?? null);
|
||||
setIsAuthResolving(!!session?.user);
|
||||
if (session?.user) {
|
||||
try {
|
||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||
@@ -137,11 +143,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setAccessToken(null);
|
||||
}
|
||||
setLoading(false);
|
||||
setIsAuthResolving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === 'SIGNED_OUT') {
|
||||
persistSession(null);
|
||||
setIsAuthResolving(false);
|
||||
setUser(null);
|
||||
setUserRole(null);
|
||||
setAccessToken(null);
|
||||
@@ -214,7 +222,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
signIn,
|
||||
signOut,
|
||||
clearError,
|
||||
bootstrapData
|
||||
bootstrapData,
|
||||
isAuthResolving
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type LogCategory =
|
||||
| 'neo-institute-context'
|
||||
| 'auth-service'
|
||||
| 'graph-service'
|
||||
| 'graph-sidebar'
|
||||
| 'registration-service'
|
||||
| 'provisioning-service'
|
||||
| 'snapshot-service'
|
||||
@@ -111,6 +112,7 @@ export type LogCategory =
|
||||
| 'user-context'
|
||||
| 'neo-user-context'
|
||||
| 'neo-institute-context'
|
||||
| 'cc-live-transcription'
|
||||
| 'search-service';
|
||||
|
||||
interface LogConfig {
|
||||
|
||||
+4
-5
@@ -1,7 +1,6 @@
|
||||
/* src/index.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
/* Tailwind removed — utility classes provided by tailwind-compat.css shim */
|
||||
@import './tailwind-compat.css';
|
||||
|
||||
/* Base styles */
|
||||
html,
|
||||
@@ -152,10 +151,10 @@ body {
|
||||
|
||||
.login-role-header {
|
||||
font-weight: 600 !important;
|
||||
color: #1976d2 !important;
|
||||
color: var(--cc-action-primary) !important;
|
||||
margin-bottom: 24px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-bottom: 2px solid #1976d2 !important;
|
||||
border-bottom: 2px solid var(--cc-action-primary) !important;
|
||||
width: fit-content !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,34 +6,50 @@ import { EmailLoginForm } from './EmailLoginForm';
|
||||
import { EmailCredentials } from '../../services/auth/authService';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const getLoginErrorMessage = (error: unknown): string => {
|
||||
const status = (error as { status?: number } | null)?.status;
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
|
||||
if (code === 'email_not_confirmed' || status === 401 && code === 'invalid_grant') {
|
||||
return 'Please confirm your email before logging in';
|
||||
}
|
||||
|
||||
if (status === 401 || code === 'invalid_grant') {
|
||||
return 'Invalid email or password';
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : 'Login failed';
|
||||
};
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user, signIn } = useAuth();
|
||||
const { user, signIn, isAuthResolving } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
logger.debug('login-page', '🔍 Login page loaded', {
|
||||
hasUser: !!user
|
||||
hasUser: !!user,
|
||||
isAuthResolving
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
if (!isAuthResolving && user) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
}, [user, isAuthResolving, navigate]);
|
||||
|
||||
const handleLogin = async (credentials: EmailCredentials) => {
|
||||
try {
|
||||
setError(null);
|
||||
await signIn(credentials.email, credentials.password);
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
logger.error('login-page', '❌ Login failed', error);
|
||||
setError(error instanceof Error ? error.message : 'Login failed');
|
||||
const message = getLoginErrorMessage(error);
|
||||
setError(message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (user) {
|
||||
if (user && !isAuthResolving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CloudUpload as UploadIcon,
|
||||
Upload,
|
||||
Folder as FolderIcon,
|
||||
FolderOpen as FolderOpenIcon,
|
||||
Description as FileIcon,
|
||||
@@ -39,8 +40,12 @@ import {
|
||||
PlayArrow as ProcessIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Info as InfoIcon
|
||||
Info as InfoIcon,
|
||||
InsertDriveFile,
|
||||
} from '@mui/icons-material';
|
||||
const FolderOpen = FolderOpenIcon;
|
||||
const Refresh = RefreshIcon;
|
||||
const Info = InfoIcon;
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import {
|
||||
pickDirectory,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
import type { ExamTemplate } from '../../types/exam.types';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> = {
|
||||
draft: 'warning',
|
||||
ready: 'success',
|
||||
archived: 'default',
|
||||
};
|
||||
|
||||
const ExamDashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { bootstrapData } = useAuth();
|
||||
const instituteId = bootstrapData?.active_institute?.id ?? undefined;
|
||||
|
||||
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTemplates(await examRepository.listTemplates());
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.warn('cc-exam-marker', 'Failed to load templates', { message: msg });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, instituteId]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await examRepository.createTemplate({
|
||||
title: title.trim(),
|
||||
subject: subject.trim() || undefined,
|
||||
institute_id: instituteId,
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setTitle('');
|
||||
setSubject('');
|
||||
navigate(`/exam-marker/${created.id}/setup`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Create template failed', { message: msg });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string, ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
try {
|
||||
await examRepository.archiveTemplate(id);
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Stack spacing={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
Exam Marker
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||
Build a template for an exam paper, then run marking batches against your classes.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New template
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : templates.length === 0 ? (
|
||||
<Paper variant="outlined" sx={{ p: 6, textAlign: 'center' }}>
|
||||
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Create your first template to start mapping an exam paper.
|
||||
</Typography>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New template
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{templates.map((t) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
||||
<Tooltip title="Archive">
|
||||
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
||||
<ArchiveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{t.subject && (
|
||||
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
||||
)}
|
||||
{t.exam_code && (
|
||||
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
||||
)}
|
||||
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(t.updated_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||
<DialogTitle>New exam template</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
fullWidth
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||
{saving ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamDashboardPage;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||
@@ -0,0 +1,263 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import SaveIcon from '@mui/icons-material/Save'
|
||||
import MouseIcon from '@mui/icons-material/Mouse'
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
||||
import '@tldraw/tldraw/tldraw.css'
|
||||
import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw'
|
||||
import axios from 'axios'
|
||||
|
||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||
import { logger } from '../../../debugConfig'
|
||||
import { examRepository } from '../../../services/exam/examRepository'
|
||||
import type { ExamTemplateDetail } from '../../../types/exam.types'
|
||||
import { ExamCanvasShapeModel, PAGE_WIDTH, type PdfPageLayout, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, PDF_PAGE_SHAPE_TYPE, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes'
|
||||
import { renderPdfBackdrop, replacePdfPageShapes } from './pdfLoader'
|
||||
|
||||
const TOOLS = [
|
||||
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete user regions. The PDF backdrop is locked.', color: 'default' as const },
|
||||
{ id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const },
|
||||
{ id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const },
|
||||
{ id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const },
|
||||
{ id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const },
|
||||
{ id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const },
|
||||
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const },
|
||||
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const },
|
||||
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const },
|
||||
]
|
||||
|
||||
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||
if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' }
|
||||
return { conflict: false, message: detail ?? err.message }
|
||||
}
|
||||
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
|
||||
function stripShapePrefix(id: string) {
|
||||
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||
}
|
||||
|
||||
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||
const kind = shapeTypeToKind(shape.type)
|
||||
if (!kind) return null
|
||||
const s = shape as ExamCanvasTLShape
|
||||
return {
|
||||
id: s.props.domainId || stripShapePrefix(s.id),
|
||||
kind,
|
||||
x: Number(s.x ?? 0),
|
||||
y: Number(s.y ?? 0),
|
||||
w: Number(s.props.w ?? 1),
|
||||
h: Number(s.props.h ?? 1),
|
||||
label: s.props.label,
|
||||
maxMarks: s.props.maxMarks,
|
||||
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
||||
contextType: s.props.contextType,
|
||||
questionId: s.props.questionId ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||
if (existing.length) editor.deleteShapes(existing)
|
||||
if (!models.length) return
|
||||
editor.createShapes(models.map((m) => ({
|
||||
id: createShapeId(m.id),
|
||||
type: SHAPE_TYPES[m.kind],
|
||||
x: m.x,
|
||||
y: m.y,
|
||||
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, domainId: m.id, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId },
|
||||
})))
|
||||
}
|
||||
|
||||
function newDomainId() {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||
return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function seedGuide(editor: Editor) {
|
||||
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
||||
if (current.length) return
|
||||
editor.createShapes([
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 start' } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 end' } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', domainId: newDomainId(), label: 'Q1(a)', maxMarks: 3 } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', domainId: newDomainId(), label: 'Response', responseForm: 'lines' } },
|
||||
])
|
||||
}
|
||||
|
||||
const ExamTemplateSetupInner: React.FC = () => {
|
||||
const { templateId } = useParams<{ templateId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const theme = useTheme()
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const pageLayoutsRef = useRef<PdfPageLayout[]>([])
|
||||
const hydratingRef = useRef(false)
|
||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pdfLoading, setPdfLoading] = useState(false)
|
||||
const [pdfStatus, setPdfStatus] = useState<string>('PDF not loaded')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [activeTool, setActiveTool] = useState('select')
|
||||
|
||||
const loadPdf = useCallback(async (id: string, editor: Editor) => {
|
||||
setPdfLoading(true)
|
||||
setPdfStatus('Loading PDF…')
|
||||
try {
|
||||
const result = await renderPdfBackdrop(id)
|
||||
hydratingRef.current = true
|
||||
replacePdfPageShapes(editor, result.pages)
|
||||
pageLayoutsRef.current = result.pageLayouts
|
||||
setPdfStatus(`${result.pages.length} PDF page${result.pages.length === 1 ? '' : 's'} loaded`)
|
||||
editor.zoomToFit()
|
||||
} catch (e) {
|
||||
pageLayoutsRef.current = []
|
||||
const msg = apiMessage(e).message
|
||||
setPdfStatus(`PDF unavailable: ${msg}`)
|
||||
logger.warn('cc-exam-marker', 'Template setup PDF backdrop failed', { templateId: id, message: msg })
|
||||
} finally {
|
||||
hydratingRef.current = false
|
||||
setPdfLoading(false)
|
||||
setDirty(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!templateId) return
|
||||
setLoading(true); setError(null); setConflict(null)
|
||||
try {
|
||||
const detail = await examRepository.getTemplate(templateId)
|
||||
setTemplate(detail)
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
hydratingRef.current = true
|
||||
loadShapes(editor, shapesFromTemplate(detail))
|
||||
hydratingRef.current = false
|
||||
await loadPdf(templateId, editor)
|
||||
}
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
const msg = apiMessage(e).message
|
||||
logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg })
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadPdf, templateId])
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || !templateId || !template) return
|
||||
setSaving(true); setError(null); setConflict(null)
|
||||
try {
|
||||
const shapes = editor.getCurrentPageShapes().filter((s) => s.type !== PDF_PAGE_SHAPE_TYPE).map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||
const payload = serializeCanvasShapes(template, shapes, pageLayoutsRef.current)
|
||||
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||
setTemplate(saved)
|
||||
hydratingRef.current = true
|
||||
loadShapes(editor, shapesFromTemplate(saved))
|
||||
hydratingRef.current = false
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
const msg = apiMessage(e)
|
||||
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
|
||||
logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [template, templateId])
|
||||
|
||||
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||
<Button
|
||||
size="small"
|
||||
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
||||
color={tool.color}
|
||||
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : undefined}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
|
||||
setActiveTool(tool.id)
|
||||
}}
|
||||
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
|
||||
>
|
||||
{tool.label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)), [activeTool])
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default' }}>
|
||||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
||||
<Tldraw
|
||||
shapeUtils={examCanvasShapeUtils as any}
|
||||
bindingUtils={defaultBindingUtils}
|
||||
tools={examCanvasTools as any}
|
||||
hideUi
|
||||
inferDarkMode={theme.palette.mode === 'dark'}
|
||||
autoFocus
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
|
||||
editor.store.listen((entry) => {
|
||||
const changedPdfOnly = Object.values(entry.changes.added).every((record: any) => record?.type === PDF_PAGE_SHAPE_TYPE)
|
||||
if (!hydratingRef.current && !changedPdfOnly) setDirty(true)
|
||||
}, { scope: 'document' })
|
||||
if (template) {
|
||||
hydratingRef.current = true
|
||||
loadShapes(editor, shapesFromTemplate(template))
|
||||
hydratingRef.current = false
|
||||
if (templateId) void loadPdf(templateId, editor)
|
||||
} else seedGuide(editor)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={8} sx={{ position: 'absolute', top: 12, left: 12, right: 12, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} size="small">Back</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · real PDF backdrop is locked; drawn regions persist with PDF-derived page numbers.</Typography>
|
||||
</Box>
|
||||
<Chip size="small" icon={<PictureAsPdfIcon />} color={pdfStatus.includes('loaded') ? 'info' : pdfStatus.includes('unavailable') ? 'warning' : 'default'} label={pdfLoading ? 'Loading PDF…' : pdfStatus} />
|
||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={8} sx={{ position: 'absolute', top: 92, left: 12, p: 1.25, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||
<Stack spacing={1}>{toolButtons}</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
1) Use the visible PDF pages as the paper coordinate system. 2) Draw top/bottom Boundary lines, Part boxes, then regions over the real paper. 3) Save derives page numbers from the PDF page under each shape and reloads them at the same canvas location.
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}
|
||||
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 86, right: 16, maxWidth: 560 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
||||
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const ExamTemplateSetupPage: React.FC = () => (
|
||||
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
|
||||
<ExamTemplateSetupInner />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
export default ExamTemplateSetupPage
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react'
|
||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
||||
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||
|
||||
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||
|
||||
export const SHAPE_TYPES = {
|
||||
boundary: 'exam-boundary',
|
||||
part: 'exam-part',
|
||||
response: 'exam-region-response',
|
||||
context: 'exam-region-context',
|
||||
question_number: 'exam-region-question-number',
|
||||
mark_area: 'exam-region-mark-area',
|
||||
reference: 'exam-region-reference',
|
||||
furniture: 'exam-region-furniture',
|
||||
} as const
|
||||
|
||||
export type PdfPageTLShape = TLBaseBoxShape<typeof PDF_PAGE_SHAPE_TYPE, {
|
||||
w: number
|
||||
h: number
|
||||
src: string
|
||||
pageNumber: number
|
||||
}>
|
||||
|
||||
export type ExamCanvasTLShape = TLBaseBoxShape<typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES], {
|
||||
w: number
|
||||
h: number
|
||||
label: string
|
||||
kind: ExamCanvasShapeKind
|
||||
domainId: string
|
||||
maxMarks?: number
|
||||
responseForm?: string
|
||||
contextType?: string
|
||||
questionId?: string | null
|
||||
}>
|
||||
|
||||
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
|
||||
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
|
||||
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
|
||||
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
|
||||
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
|
||||
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
|
||||
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
|
||||
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
|
||||
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
|
||||
}
|
||||
|
||||
function makeDomainId() {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
||||
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
||||
)
|
||||
}
|
||||
|
||||
function renderShape(shape: ExamCanvasTLShape) {
|
||||
const kind = shape.props.kind
|
||||
const p = palette[kind] ?? palette.response
|
||||
const isBoundary = kind === 'boundary'
|
||||
return (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
|
||||
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||
background: isBoundary ? 'transparent' : p.fill, color: p.stroke, fontFamily: 'Inter, system-ui, sans-serif',
|
||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? 'none' : '0 10px 22px rgba(15,23,42,0.08)', overflow: 'hidden'
|
||||
}}>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
|
||||
{shape.props.label || p.label}
|
||||
</span>
|
||||
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>}
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||
const p = palette[kind]
|
||||
return { w, h, label: p.label, kind, domainId: makeDomainId(), responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||
}
|
||||
|
||||
class PdfPageUtil extends BaseBoxShapeUtil<PdfPageTLShape> {
|
||||
static override type = PDF_PAGE_SHAPE_TYPE
|
||||
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
|
||||
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
|
||||
override canEdit() { return false }
|
||||
override canResize() { return false }
|
||||
override hideResizeHandles() { return true }
|
||||
override hideRotateHandle() { return true }
|
||||
override component(shape: PdfPageTLShape) {
|
||||
return (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||
<img
|
||||
alt={`PDF page ${shape.props.pageNumber}`}
|
||||
src={shape.props.src}
|
||||
draggable={false}
|
||||
style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', boxShadow: '0 12px 26px rgba(15,23,42,0.22)', background: '#fff' }}
|
||||
/>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, domainId: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } }
|
||||
|
||||
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
|
||||
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
|
||||
class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response }
|
||||
class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context }
|
||||
class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number }
|
||||
class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area }
|
||||
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference }
|
||||
class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture }
|
||||
|
||||
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
|
||||
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
|
||||
|
||||
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Editor } from '@tldraw/tldraw'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs?url'
|
||||
|
||||
import { examRepository } from '../../../services/exam/examRepository'
|
||||
import { PAGE_GAP, PAGE_WIDTH, type PdfPageLayout } from '../../../utils/exam-canvas/model'
|
||||
import { PDF_PAGE_SHAPE_TYPE, type PdfPageTLShape } from './examCanvasShapes'
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||
|
||||
export interface PdfBackdropPage {
|
||||
page: number
|
||||
src: string
|
||||
layout: PdfPageLayout
|
||||
}
|
||||
|
||||
export interface PdfBackdropResult {
|
||||
pages: PdfBackdropPage[]
|
||||
pageLayouts: PdfPageLayout[]
|
||||
}
|
||||
|
||||
export async function renderPdfBackdrop(templateId: string): Promise<PdfBackdropResult> {
|
||||
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(bytes) })
|
||||
const pdf = await loadingTask.promise
|
||||
const pages: PdfBackdropPage[] = []
|
||||
let y = 0
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
const page = await pdf.getPage(pageNumber)
|
||||
const nativeViewport = page.getViewport({ scale: 1 })
|
||||
const layoutScale = PAGE_WIDTH / nativeViewport.width
|
||||
const renderScale = layoutScale * Math.min(window.devicePixelRatio || 1, 2)
|
||||
const renderViewport = page.getViewport({ scale: renderScale })
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Could not create 2D canvas for PDF rendering')
|
||||
canvas.width = Math.ceil(renderViewport.width)
|
||||
canvas.height = Math.ceil(renderViewport.height)
|
||||
await page.render({ canvasContext: context, viewport: renderViewport }).promise
|
||||
|
||||
const layout: PdfPageLayout = {
|
||||
page: pageNumber,
|
||||
x: 0,
|
||||
y,
|
||||
w: PAGE_WIDTH,
|
||||
h: nativeViewport.height * layoutScale,
|
||||
}
|
||||
pages.push({ page: pageNumber, src: canvas.toDataURL('image/jpeg', 0.9), layout })
|
||||
y += layout.h + PAGE_GAP
|
||||
}
|
||||
|
||||
await pdf.destroy()
|
||||
return { pages, pageLayouts: pages.map((p) => p.layout) }
|
||||
}
|
||||
|
||||
export function replacePdfPageShapes(editor: Editor, pages: PdfBackdropPage[]) {
|
||||
const old = editor.getCurrentPageShapes().filter((shape) => shape.type === PDF_PAGE_SHAPE_TYPE).map((shape) => shape.id)
|
||||
if (old.length) editor.deleteShapes(old)
|
||||
if (!pages.length) return
|
||||
|
||||
editor.createShapes(pages.map((page) => ({
|
||||
id: `shape:pdf-page-${page.page}` as PdfPageTLShape['id'],
|
||||
type: PDF_PAGE_SHAPE_TYPE,
|
||||
x: page.layout.x,
|
||||
y: page.layout.y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
w: page.layout.w,
|
||||
h: page.layout.h,
|
||||
src: page.src,
|
||||
pageNumber: page.page,
|
||||
},
|
||||
})))
|
||||
}
|
||||
@@ -3,9 +3,19 @@ import { Link } from 'react-router-dom';
|
||||
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
|
||||
import useTimetableStore from '../../stores/timetableStore';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { logger } from '../../debugConfig';
|
||||
|
||||
const MyClassesSkeleton = () => (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
|
||||
const MyClassesPage: React.FC = () => {
|
||||
const { profile } = useUser();
|
||||
const { selectedInstituteId } = useNeoInstitute();
|
||||
const {
|
||||
myClasses,
|
||||
classesLoading: myClassesLoading,
|
||||
@@ -14,8 +24,16 @@ const MyClassesPage: React.FC = () => {
|
||||
} = useTimetableStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyClasses();
|
||||
}, [fetchMyClasses]);
|
||||
fetchMyClasses().catch((error) =>
|
||||
logger.warn('my-classes', 'Fetch my classes failed', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
}, [fetchMyClasses, selectedInstituteId]);
|
||||
|
||||
if (myClassesLoading) {
|
||||
return <MyClassesSkeleton />;
|
||||
}
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
@@ -36,14 +54,6 @@ const MyClassesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (myClassesLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (myClassesError) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarToday, KeyboardArrowRight } from '@mui/icons-material';
|
||||
import useTimetableStore from '../../stores/timetableStore';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
const TimetableListPage: React.FC = () => {
|
||||
const { timetables, timetablesLoading, timetablesError, fetchTimetables } = useTimetableStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTimetables();
|
||||
}, [fetchTimetables]);
|
||||
|
||||
if (timetablesLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Treat fetch errors (including 404 — endpoint not yet available) as empty state
|
||||
const showEmpty = timetablesError || timetables.length === 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Timetables</h1>
|
||||
<p className="mt-1 text-gray-500">Your scheduled timetables</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEmpty ? (
|
||||
<div className="text-center py-16">
|
||||
<CalendarToday sx={{ fontSize: 48 }} className="mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium text-gray-900">No timetables yet</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Timetables are created from a class. Go to{' '}
|
||||
<Link to="/classes" className="text-blue-600 hover:underline">Classes</Link>{' '}
|
||||
to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{timetables.map((timetable) => (
|
||||
<Link
|
||||
key={timetable.id}
|
||||
to={`/timetable/${timetable.id}`}
|
||||
className="flex items-center gap-4 p-4 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CalendarToday className="text-blue-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{timetable.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{format(parseISO(timetable.start_date), 'MMM d, yyyy')}
|
||||
{timetable.end_date
|
||||
? ` – ${format(parseISO(timetable.end_date), 'MMM d, yyyy')}`
|
||||
: ' (ongoing)'}
|
||||
</p>
|
||||
</div>
|
||||
{timetable.recurrence_rule && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full flex-shrink-0">
|
||||
Recurring
|
||||
</span>
|
||||
)}
|
||||
<KeyboardArrowRight className="text-gray-400 flex-shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimetableListPage;
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export { default as TimetablePage } from './TimetablePage';
|
||||
export { default as TimetableListPage } from './TimetableListPage';
|
||||
export { default as ClassesPage } from './ClassesPage';
|
||||
export { default as LessonPage } from './LessonPage';
|
||||
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { TLShapeId } from '@tldraw/tldraw';
|
||||
|
||||
export interface AnnotationData {
|
||||
studentIndex?: number; // undefined for exam/markscheme annotations
|
||||
pageIndex: number;
|
||||
shapeId: TLShapeId;
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class AnnotationManager {
|
||||
private examAnnotations: Set<TLShapeId> = new Set();
|
||||
private markSchemeAnnotations: Set<TLShapeId> = new Set();
|
||||
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
|
||||
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
|
||||
|
||||
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
|
||||
this.annotationData.set(shapeId, data);
|
||||
|
||||
if (data.studentIndex !== undefined) {
|
||||
// Student response annotation
|
||||
let studentSet = this.studentAnnotations.get(data.studentIndex);
|
||||
if (!studentSet) {
|
||||
studentSet = new Set();
|
||||
this.studentAnnotations.set(data.studentIndex, studentSet);
|
||||
}
|
||||
studentSet.add(shapeId);
|
||||
} else {
|
||||
// Exam or mark scheme annotation
|
||||
if (data.pageIndex < 0) {
|
||||
this.examAnnotations.add(shapeId);
|
||||
} else {
|
||||
this.markSchemeAnnotations.add(shapeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeAnnotation(shapeId: TLShapeId) {
|
||||
const data = this.annotationData.get(shapeId);
|
||||
if (!data) return;
|
||||
|
||||
if (data.studentIndex !== undefined) {
|
||||
const studentSet = this.studentAnnotations.get(data.studentIndex);
|
||||
studentSet?.delete(shapeId);
|
||||
} else {
|
||||
if (data.pageIndex < 0) {
|
||||
this.examAnnotations.delete(shapeId);
|
||||
} else {
|
||||
this.markSchemeAnnotations.delete(shapeId);
|
||||
}
|
||||
}
|
||||
this.annotationData.delete(shapeId);
|
||||
}
|
||||
|
||||
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
|
||||
return Array.from(this.studentAnnotations.get(studentIndex) || []);
|
||||
}
|
||||
|
||||
getAnnotationsForExam(): TLShapeId[] {
|
||||
return Array.from(this.examAnnotations);
|
||||
}
|
||||
|
||||
getAnnotationsForMarkScheme(): TLShapeId[] {
|
||||
return Array.from(this.markSchemeAnnotations);
|
||||
}
|
||||
|
||||
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
|
||||
return this.annotationData.get(shapeId);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.examAnnotations.clear();
|
||||
this.markSchemeAnnotations.clear();
|
||||
this.studentAnnotations.clear();
|
||||
this.annotationData.clear();
|
||||
}
|
||||
|
||||
// Future transcription support
|
||||
addTranscriptionToAnnotation(shapeId: TLShapeId) {
|
||||
const data = this.annotationData.get(shapeId);
|
||||
if (data) {
|
||||
this.annotationData.set(shapeId, {
|
||||
...data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import 'tldraw/tldraw.css';
|
||||
import { CCPdfEditor } from './CCPdfEditor';
|
||||
import { CCPdfPicker } from './CCPdfPicker';
|
||||
import { ExamPdfState } from './types';
|
||||
import './cc-exam-marker.css';
|
||||
import { HEADER_HEIGHT } from '../../Layout';
|
||||
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
|
||||
|
||||
export const CCExamMarker = () => {
|
||||
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
|
||||
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
|
||||
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
|
||||
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
|
||||
setView(newView);
|
||||
};
|
||||
|
||||
const handleNextStudent = () => {
|
||||
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
|
||||
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
|
||||
if (currentStudentIndex < totalStudents - 1) {
|
||||
setCurrentStudentIndex(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousStudent = () => {
|
||||
if (currentStudentIndex > 0) {
|
||||
setCurrentStudentIndex(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: 'background.default',
|
||||
color: 'text.primary',
|
||||
}}>
|
||||
{state.phase === 'pick' ? (
|
||||
<CCPdfPicker
|
||||
onOpenPdfs={(pdfs) =>
|
||||
setState({
|
||||
phase: 'edit',
|
||||
examPaper: pdfs.examPaper,
|
||||
markScheme: pdfs.markScheme,
|
||||
studentResponses: pdfs.studentResponses,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
bgcolor: 'background.paper',
|
||||
}}>
|
||||
<CCPdfEditor
|
||||
examPaper={state.examPaper}
|
||||
markScheme={state.markScheme}
|
||||
studentResponses={state.studentResponses}
|
||||
currentView={view}
|
||||
currentStudentIndex={currentStudentIndex}
|
||||
onEditorMount={(editor) => {
|
||||
if (!editor) return null;
|
||||
const examMarkerProps = {
|
||||
editor,
|
||||
currentView: view,
|
||||
onViewChange: handleViewChange,
|
||||
currentStudentIndex,
|
||||
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
|
||||
onPreviousStudent: handlePreviousStudent,
|
||||
onNextStudent: handleNextStudent,
|
||||
getCurrentPdf: () => {
|
||||
if (!editor) return null;
|
||||
const currentPageId = editor.getCurrentPageId();
|
||||
if (currentPageId.includes('exam-page')) {
|
||||
return state.examPaper;
|
||||
} else if (currentPageId.includes('mark-scheme-page')) {
|
||||
return state.markScheme;
|
||||
} else if (currentPageId.includes('student-response')) {
|
||||
return state.studentResponses;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return <CCPanel
|
||||
examMarkerProps={examMarkerProps}
|
||||
isExpanded={isExpanded}
|
||||
isPinned={isPinned}
|
||||
onExpandedChange={setIsExpanded}
|
||||
onPinnedChange={setIsPinned}
|
||||
/>;
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { useState } from 'react';
|
||||
import { Editor, exportToBlob } from '@tldraw/tldraw';
|
||||
import { Button } from '@mui/material';
|
||||
import { Pdf } from './types';
|
||||
|
||||
interface CCExportPdfButtonProps {
|
||||
editor: Editor;
|
||||
pdf: Pdf;
|
||||
}
|
||||
|
||||
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
|
||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||||
|
||||
const exportPdf = async (
|
||||
editor: Editor,
|
||||
{ name, source, pages }: Pdf,
|
||||
onProgress: (progress: number) => void
|
||||
) => {
|
||||
const totalThings = pages.length * 2 + 2;
|
||||
let progressCount = 0;
|
||||
const tickProgress = () => {
|
||||
progressCount++;
|
||||
onProgress(progressCount / totalThings);
|
||||
};
|
||||
|
||||
const pdf = await PDFDocument.load(source);
|
||||
tickProgress();
|
||||
const pdfPages = pdf.getPages();
|
||||
|
||||
if (pdfPages.length !== pages.length) {
|
||||
throw new Error('PDF page count mismatch');
|
||||
}
|
||||
|
||||
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
|
||||
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
|
||||
(id) => !pageShapeIds.has(id)
|
||||
);
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
const pdfPage = pdfPages[i];
|
||||
const {bounds} = page;
|
||||
|
||||
const shapesInBounds = allIds.filter((id) => {
|
||||
const shapePageBounds = editor.getShapePageBounds(id);
|
||||
if (!shapePageBounds) return false;
|
||||
return shapePageBounds.collides(bounds);
|
||||
});
|
||||
|
||||
if (shapesInBounds.length === 0) {
|
||||
tickProgress();
|
||||
tickProgress();
|
||||
continue;
|
||||
}
|
||||
|
||||
const exportedPng = await exportToBlob({
|
||||
editor,
|
||||
ids: allIds,
|
||||
format: 'png',
|
||||
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
|
||||
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pdfPage.getWidth(),
|
||||
height: pdfPage.getHeight(),
|
||||
});
|
||||
|
||||
tickProgress();
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([await pdf.save()], { type: 'application/pdf' })
|
||||
);
|
||||
tickProgress();
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="CCExportPdfButton"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
setExportProgress(0);
|
||||
try {
|
||||
await exportPdf(editor, pdf, setExportProgress);
|
||||
} finally {
|
||||
setExportProgress(null);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{exportProgress
|
||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
||||
: 'Export PDF'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
|
||||
import { Tldraw } from '@tldraw/tldraw';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { ExamPdfs } from './types';
|
||||
import { AnnotationManager, AnnotationData } from './AnnotationManager';
|
||||
import { logger } from '../../../debugConfig';
|
||||
|
||||
const PAGE_SPACING = 32; // Same spacing as the example
|
||||
|
||||
interface CCPdfEditorProps extends ExamPdfs {
|
||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
||||
currentStudentIndex: number;
|
||||
onEditorMount: (editor: Editor) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function CCPdfEditor({
|
||||
examPaper,
|
||||
markScheme,
|
||||
studentResponses,
|
||||
currentView,
|
||||
currentStudentIndex,
|
||||
onEditorMount,
|
||||
}: CCPdfEditorProps) {
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [pagesInitialized, setPagesInitialized] = useState(false);
|
||||
const annotationManager = useRef(new AnnotationManager());
|
||||
|
||||
const handleMount = useCallback((editor: Editor) => {
|
||||
setEditor(editor);
|
||||
onEditorMount(editor);
|
||||
|
||||
// Subscribe to shape changes
|
||||
editor.on('change', () => {
|
||||
const shapes = editor.getCurrentPageShapeIds();
|
||||
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
|
||||
totalShapes: shapes.size,
|
||||
currentPage: editor.getCurrentPageId()
|
||||
});
|
||||
|
||||
shapes.forEach(shapeId => {
|
||||
const shape = editor.getShape(shapeId);
|
||||
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
|
||||
const bounds = editor.getShapePageBounds(shapeId);
|
||||
if (bounds) {
|
||||
const currentPageId = editor.getCurrentPageId();
|
||||
let annotationData: AnnotationData;
|
||||
|
||||
if (currentPageId.includes('student-response')) {
|
||||
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
|
||||
|
||||
// Find which page this annotation belongs to by checking collision with page bounds
|
||||
const pageShapes = Array.from(shapes).filter(id => {
|
||||
const s = editor.getShape(id);
|
||||
return s?.isLocked; // Locked shapes are our PDF pages
|
||||
});
|
||||
|
||||
let pageIndex = -1; // Default to -1 if no collision found
|
||||
for (let i = 0; i < pageShapes.length; i++) {
|
||||
const pageShape = editor.getShape(pageShapes[i]);
|
||||
if (!pageShape) continue;
|
||||
|
||||
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
|
||||
if (!pageBounds) continue;
|
||||
|
||||
// Check if the annotation's center point is within the page bounds
|
||||
const annotationCenter = {
|
||||
x: bounds.x + bounds.width / 2,
|
||||
y: bounds.y + bounds.height / 2
|
||||
};
|
||||
|
||||
if (annotationCenter.x >= pageBounds.x &&
|
||||
annotationCenter.x <= pageBounds.x + pageBounds.width &&
|
||||
annotationCenter.y >= pageBounds.y &&
|
||||
annotationCenter.y <= pageBounds.y + pageBounds.height) {
|
||||
pageIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('cc-exam-marker', '📏 Calculated page index', {
|
||||
shapeId,
|
||||
shapeBounds: bounds,
|
||||
pageIndex,
|
||||
studentIndex
|
||||
});
|
||||
|
||||
annotationData = {
|
||||
studentIndex,
|
||||
pageIndex,
|
||||
shapeId,
|
||||
bounds: {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// For exam/mark scheme, use current page type as index
|
||||
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
|
||||
annotationData = {
|
||||
pageIndex,
|
||||
shapeId,
|
||||
bounds: {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
|
||||
shapeId,
|
||||
annotationData,
|
||||
currentPage: currentPageId
|
||||
});
|
||||
|
||||
annotationManager.current.addAnnotation(shapeId, annotationData);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [onEditorMount]);
|
||||
|
||||
// Initial setup effect - runs only once when editor is mounted
|
||||
useEffect(() => {
|
||||
if (!editor || pagesInitialized) return;
|
||||
|
||||
const setupExamAndMarkScheme = async () => {
|
||||
const examPageId = 'page:exam-page' as TLPageId;
|
||||
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
|
||||
|
||||
// Calculate vertical layout for exam pages
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
const examPages = examPaper.pages.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = top;
|
||||
top += height + PAGE_SPACING;
|
||||
widest = Math.max(widest, width);
|
||||
return { ...page, top: currentTop, width, height };
|
||||
});
|
||||
|
||||
// Center pages horizontally
|
||||
examPages.forEach(page => {
|
||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
||||
});
|
||||
|
||||
// Create exam paper page
|
||||
editor.createPage({
|
||||
id: examPageId,
|
||||
name: 'Exam Paper',
|
||||
});
|
||||
editor.setCurrentPage(examPageId);
|
||||
|
||||
// Create assets and shapes for exam pages
|
||||
examPages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Similar process for mark scheme pages
|
||||
let markSchemeTop = 0;
|
||||
const markSchemePages = markScheme.pages.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = markSchemeTop;
|
||||
markSchemeTop += height + PAGE_SPACING;
|
||||
return {
|
||||
...page,
|
||||
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
|
||||
};
|
||||
});
|
||||
|
||||
// Create mark scheme page
|
||||
editor.createPage({
|
||||
id: markSchemePageId,
|
||||
name: 'Mark Scheme',
|
||||
});
|
||||
editor.setCurrentPage(markSchemePageId);
|
||||
|
||||
// Create assets and shapes for mark scheme pages
|
||||
markSchemePages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Go back to exam page
|
||||
editor.setCurrentPage(examPageId);
|
||||
};
|
||||
|
||||
const setupStudentResponses = async () => {
|
||||
const pagesPerStudent = examPaper.pages.length;
|
||||
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
|
||||
|
||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
||||
const startPage = studentIndex * pagesPerStudent;
|
||||
const endPage = startPage + pagesPerStudent;
|
||||
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
|
||||
|
||||
// Calculate vertical layout
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
const studentPages = studentResponses.pages
|
||||
.slice(startPage, endPage)
|
||||
.map(page => {
|
||||
const width = page.bounds.width;
|
||||
const height = page.bounds.height;
|
||||
const currentTop = top;
|
||||
top += height + PAGE_SPACING;
|
||||
widest = Math.max(widest, width);
|
||||
return { ...page, top: currentTop, width, height };
|
||||
});
|
||||
|
||||
// Center pages horizontally
|
||||
studentPages.forEach(page => {
|
||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
||||
});
|
||||
|
||||
// Create page for this student
|
||||
editor.createPage({
|
||||
id: studentPageId,
|
||||
name: `Student ${studentIndex + 1}`,
|
||||
});
|
||||
editor.setCurrentPage(studentPageId);
|
||||
|
||||
// Create assets and shapes
|
||||
studentPages.forEach((page) => {
|
||||
editor.createAssets([{
|
||||
id: page.assetId,
|
||||
typeName: 'asset',
|
||||
type: 'image',
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
name: 'PDF Page',
|
||||
src: page.src,
|
||||
isAnimated: false,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
meta: {},
|
||||
}]);
|
||||
|
||||
editor.createShape({
|
||||
id: page.shapeId,
|
||||
type: 'image',
|
||||
x: page.bounds.x,
|
||||
y: page.bounds.y,
|
||||
props: {
|
||||
w: page.bounds.width,
|
||||
h: page.bounds.height,
|
||||
assetId: page.assetId,
|
||||
},
|
||||
isLocked: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial setup of all pages
|
||||
const setup = async () => {
|
||||
await setupExamAndMarkScheme();
|
||||
await setupStudentResponses();
|
||||
setPagesInitialized(true);
|
||||
};
|
||||
|
||||
setup();
|
||||
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
|
||||
|
||||
// Effect to handle view changes and navigation
|
||||
useEffect(() => {
|
||||
if (!editor || !pagesInitialized) return;
|
||||
|
||||
// Switch to appropriate page based on current view
|
||||
const targetPageId = currentView === 'exam-and-markscheme'
|
||||
? ('page:exam-page' as TLPageId)
|
||||
: (`page:student-response-${currentStudentIndex}` as TLPageId);
|
||||
|
||||
logger.debug('cc-exam-marker', '🔄 Switching view', {
|
||||
currentView,
|
||||
currentStudentIndex,
|
||||
targetPageId
|
||||
});
|
||||
|
||||
editor.setCurrentPage(targetPageId);
|
||||
|
||||
// Update camera constraints for current page
|
||||
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
|
||||
(acc: TLBox | null, shapeId) => {
|
||||
const bounds = editor.getShapePageBounds(shapeId);
|
||||
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
|
||||
},
|
||||
null as TLBox | null
|
||||
);
|
||||
|
||||
if (currentPageBounds) {
|
||||
const isMobile = editor.getViewportScreenBounds().width < 840;
|
||||
editor.setCameraOptions({
|
||||
constraints: {
|
||||
bounds: currentPageBounds,
|
||||
padding: { x: isMobile ? 16 : 164, y: 64 },
|
||||
origin: { x: 0.5, y: 0 },
|
||||
initialZoom: 'fit-x-100',
|
||||
baseZoom: 'default',
|
||||
behavior: 'contain',
|
||||
},
|
||||
});
|
||||
editor.setCamera(editor.getCamera(), { reset: true });
|
||||
}
|
||||
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
|
||||
|
||||
// Expose annotationManager to parent through onEditorMount
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
onEditorMount(editor);
|
||||
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
|
||||
editor.annotationManager = annotationManager.current;
|
||||
}
|
||||
}, [editor, onEditorMount]);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Tldraw
|
||||
onMount={handleMount}
|
||||
components={{
|
||||
InFrontOfTheCanvas: () => onEditorMount(editor!)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
||||
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
|
||||
import { ExamPdfs, Pdf, PdfPage } from './types';
|
||||
|
||||
interface CCPdfPickerProps {
|
||||
onOpenPdfs: (pdfs: ExamPdfs) => void;
|
||||
}
|
||||
|
||||
const pageSpacing = 32;
|
||||
|
||||
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
|
||||
|
||||
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
|
||||
const PdfJS = await import('pdfjs-dist');
|
||||
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const pdf = await PdfJS.getDocument(source.slice()).promise;
|
||||
const pages: PdfPage[] = [];
|
||||
const canvas = window.document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Failed to create canvas context');
|
||||
|
||||
const visualScale = 1.5;
|
||||
const scale = window.devicePixelRatio;
|
||||
let top = 0;
|
||||
let widest = 0;
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: scale * visualScale });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
const width = viewport.width / scale;
|
||||
const height = viewport.height / scale;
|
||||
|
||||
pages.push({
|
||||
src: canvas.toDataURL(),
|
||||
bounds: new TLBox(0, top, width, height),
|
||||
assetId: AssetRecordType.createId(),
|
||||
shapeId: createShapeId(),
|
||||
});
|
||||
|
||||
top += height + pageSpacing;
|
||||
widest = Math.max(widest, width);
|
||||
}
|
||||
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
for (const page of pages) {
|
||||
page.bounds.x = (widest - page.bounds.width) / 2;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
pages,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const pdf = await loadPdf(file.name, await file.arrayBuffer());
|
||||
|
||||
// Validate student responses page count
|
||||
if (type === 'studentResponses' && selectedPdfs.examPaper) {
|
||||
const examPageCount = selectedPdfs.examPaper.pages.length;
|
||||
if (pdf.pages.length % examPageCount !== 0) {
|
||||
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createFileInput = (type: keyof ExamPdfs) => {
|
||||
const input = window.document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/pdf';
|
||||
input.addEventListener('change', async (e) => {
|
||||
const fileList = (e.target as HTMLInputElement).files;
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
await handleFileSelect(type, fileList[0]);
|
||||
});
|
||||
input.click();
|
||||
};
|
||||
|
||||
const allPdfsSelected = () => {
|
||||
return (
|
||||
selectedPdfs.examPaper &&
|
||||
selectedPdfs.markScheme &&
|
||||
selectedPdfs.studentResponses
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="CCPdfPicker" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Typography>Loading...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="CCPdfPicker" sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Stack
|
||||
spacing={4}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">Select PDF Files</Typography>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('examPaper')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('markScheme')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
|
||||
onClick={() => createFileInput('studentResponses')}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{allPdfsSelected() && (
|
||||
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
|
||||
sx={{
|
||||
minWidth: '180px',
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.CCExamMarker {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfPicker {
|
||||
position: absolute;
|
||||
inset: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfBgRenderer {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCPdfBgRenderer img {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.CCExamMarker .PageOverlayScreen-screen {
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
fill: var(--color-background);
|
||||
fill-opacity: 0.8;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.CCExamMarker .PageOverlayScreen-outline {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
|
||||
.CCExamMarker .CCExportPdfButton {
|
||||
font: inherit;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
color: var(--color-selected-contrast);
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin: 6px;
|
||||
margin-bottom: 0;
|
||||
pointer-events: all;
|
||||
z-index: var(--layer-panels);
|
||||
border: 2px solid var(--color-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.CCExamMarker .CCExportPdfButton:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
|
||||
|
||||
export interface PdfPage {
|
||||
src: string;
|
||||
bounds: Box;
|
||||
assetId: TLAssetId;
|
||||
shapeId: TLShapeId;
|
||||
}
|
||||
|
||||
export interface Pdf {
|
||||
name: string;
|
||||
pages: PdfPage[];
|
||||
source: string | ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface ExamPdfs {
|
||||
examPaper: Pdf;
|
||||
markScheme: Pdf;
|
||||
studentResponses: Pdf;
|
||||
}
|
||||
|
||||
export type ExamPdfState =
|
||||
| {
|
||||
phase: 'pick';
|
||||
}
|
||||
| {
|
||||
phase: 'edit';
|
||||
examPaper: Pdf;
|
||||
markScheme: Pdf;
|
||||
studentResponses: Pdf;
|
||||
};
|
||||
|
||||
export interface StudentResponse {
|
||||
studentId: string;
|
||||
pageStart: number;
|
||||
pageEnd: number;
|
||||
}
|
||||
|
||||
export interface ExamMetadata {
|
||||
totalPages: number;
|
||||
pagesPerStudent: number;
|
||||
totalStudents: number;
|
||||
studentResponses: StudentResponse[];
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
TLStore,
|
||||
TLStoreWithStatus
|
||||
createTLStore,
|
||||
} from '@tldraw/tldraw';
|
||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
@@ -15,7 +15,6 @@ import { useUser } from '../../contexts/UserContext';
|
||||
// Tldraw services
|
||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||
// Tldraw utils
|
||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||
@@ -34,9 +33,7 @@ import '../../utils/tldraw/tldraw.css';
|
||||
// App debug
|
||||
import { logger } from '../../debugConfig';
|
||||
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
||||
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
|
||||
import { NodeData } from '../../types/graph-shape';
|
||||
import { NavigationNode } from '../../types/navigation';
|
||||
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||
|
||||
interface LoadingState {
|
||||
status: 'ready' | 'loading' | 'error';
|
||||
@@ -67,9 +64,8 @@ export default function SinglePlayerPage() {
|
||||
status: 'ready',
|
||||
error: ''
|
||||
});
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
|
||||
const storeRef = useRef<TLStore | null>(null);
|
||||
const [storeReady, setStoreReady] = useState(false);
|
||||
|
||||
// TLDraw user preferences
|
||||
const tldrawUser = useTldrawUser({
|
||||
@@ -85,248 +81,97 @@ export default function SinglePlayerPage() {
|
||||
setUserPreferences: setTldrawPreferences
|
||||
});
|
||||
|
||||
// Initialize store — runs as soon as user is ready, no editor needed for store creation
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
logger.debug('single-player-page', '⏳ Waiting for user data');
|
||||
return;
|
||||
}
|
||||
const [canvasPhase, setCanvasPhase] = useState<
|
||||
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
|
||||
>('idle');
|
||||
|
||||
logger.info('single-player-page', '🔄 Starting store initialization', {
|
||||
isEditorReady,
|
||||
hasUser: !!user,
|
||||
userType: user.user_type,
|
||||
username: user.username
|
||||
// Effect 1: Create the store ONCE per user session. Never recreate on node changes.
|
||||
// Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping
|
||||
// the store alive for the component lifetime avoids all disposal race conditions.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
setCanvasPhase('store-init');
|
||||
const store = createTLStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: allShapeUtils,
|
||||
bindingUtils: allBindingUtils,
|
||||
});
|
||||
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
|
||||
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||
|
||||
const snapSvc = new NavigationSnapshotService(store, undefined);
|
||||
if (accessToken) snapSvc.setAccessToken(accessToken);
|
||||
snapshotServiceRef.current = snapSvc;
|
||||
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
const unsubscribe = store.listen(() => {
|
||||
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
const initializeStoreAndSnapshot = async () => {
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
storeRef.current = store;
|
||||
setStoreReady(true);
|
||||
setCanvasPhase('ready');
|
||||
|
||||
// 1. Create store
|
||||
logger.debug('single-player-page', '🔄 Creating TLStore');
|
||||
const newStore = localStoreService.getStore({
|
||||
schema: customSchema,
|
||||
shapeUtils: allShapeUtils,
|
||||
bindingUtils: allBindingUtils
|
||||
});
|
||||
logger.debug('single-player-page', '✅ TLStore created');
|
||||
|
||||
// 2. Initialize snapshot service
|
||||
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
||||
if (accessToken) snapshotService.setAccessToken(accessToken);
|
||||
snapshotServiceRef.current = snapshotService;
|
||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
||||
|
||||
// 3. Load initial snapshot if we have a node
|
||||
if (context.node) {
|
||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
||||
if (nodeStoragePath) {
|
||||
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
||||
dbName: null,
|
||||
node: context.node,
|
||||
node_storage_path: nodeStoragePath,
|
||||
user_type: user.user_type,
|
||||
username: user.username
|
||||
});
|
||||
|
||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||
nodeStoragePath,
|
||||
accessToken || '',
|
||||
newStore,
|
||||
setLoadingState,
|
||||
undefined,
|
||||
editorRef.current || undefined
|
||||
);
|
||||
// Wire auto-save: set the current path on the service instance
|
||||
snapshotService.setCurrentNodePath(nodeStoragePath);
|
||||
logger.debug('single-player-page', '✅ Snapshot loaded from database');
|
||||
} else {
|
||||
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
|
||||
node: context.node
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
|
||||
}
|
||||
|
||||
// 4. Set up auto-save with debouncing (only after initial load is complete)
|
||||
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let isAutoSaving = false;
|
||||
|
||||
newStore.listen(() => {
|
||||
if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) {
|
||||
// Skip if already saving
|
||||
if (isAutoSaving) {
|
||||
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout);
|
||||
}
|
||||
|
||||
// Debounce auto-save to prevent excessive saves
|
||||
autoSaveTimeout = setTimeout(async () => {
|
||||
if (isAutoSaving) return; // Double-check
|
||||
|
||||
isAutoSaving = true;
|
||||
try {
|
||||
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
|
||||
await snapshotServiceRef.current?.forceSaveCurrentNode();
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Auto-save failed', error);
|
||||
} finally {
|
||||
isAutoSaving = false;
|
||||
}
|
||||
}, 2000); // Increased to 2 seconds debounce
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Update store state
|
||||
setStore(newStore);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
logger.info('single-player-page', '✅ Store initialization complete');
|
||||
|
||||
// 6. Handle cleanup
|
||||
return () => {
|
||||
logger.debug('single-player-page', '🧹 Starting cleanup');
|
||||
if (snapshotServiceRef.current) {
|
||||
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
|
||||
logger.error('single-player-page', '❌ Final save failed', error);
|
||||
});
|
||||
snapshotServiceRef.current.clearCurrentNode();
|
||||
snapshotServiceRef.current = null;
|
||||
}
|
||||
newStore.dispose();
|
||||
setStore(undefined);
|
||||
logger.debug('single-player-page', '🧹 Cleanup complete');
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
|
||||
logger.error('single-player-page', '❌ Store initialization failed', error);
|
||||
setLoadingState({ status: 'error', error: errorMessage });
|
||||
return undefined;
|
||||
}
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||
snapshotServiceRef.current?.clearCurrentNode();
|
||||
snapshotServiceRef.current = null;
|
||||
setStoreReady(false);
|
||||
setCanvasPhase('idle');
|
||||
store.dispose();
|
||||
storeRef.current = null;
|
||||
};
|
||||
}, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
|
||||
|
||||
initializeStoreAndSnapshot();
|
||||
}, [user, context.node]);
|
||||
|
||||
// Handle initial node placement
|
||||
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
|
||||
useEffect(() => {
|
||||
const placeInitialNode = async () => {
|
||||
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
|
||||
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
|
||||
hasNode: !!context.node,
|
||||
hasEditor: !!editorRef.current,
|
||||
hasStore: !!store,
|
||||
isInitialLoad
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!user || !storeRef.current) return;
|
||||
|
||||
// Debug: Log the actual node structure
|
||||
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
|
||||
node: context.node,
|
||||
nodeKeys: Object.keys(context.node),
|
||||
hasId: !!context.node.id,
|
||||
hasStoragePath: !!context.node.node_storage_path,
|
||||
hasData: !!context.node.data,
|
||||
dataKeys: context.node.data ? Object.keys(context.node.data) : null
|
||||
});
|
||||
const store = storeRef.current;
|
||||
let cancelled = false;
|
||||
|
||||
// Validate that the node has required properties
|
||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
||||
if (!context.node.id || !nodeStoragePath) {
|
||||
logger.error('single-player-page', '❌ Node missing required properties', {
|
||||
nodeId: context.node.id,
|
||||
hasStoragePath: !!nodeStoragePath,
|
||||
node: context.node
|
||||
});
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: 'Node is missing required information'
|
||||
});
|
||||
const loadNodeSnapshot = async () => {
|
||||
if (!context.node?.node_storage_path) {
|
||||
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
|
||||
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||
snapshotServiceRef.current?.clearCurrentNode();
|
||||
setCanvasPhase('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
setCanvasPhase('snapshot-loading');
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
|
||||
if (context.node.type !== 'workspace') {
|
||||
try {
|
||||
const nodeData = await loadNodeData(context.node);
|
||||
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
|
||||
} catch (shapeErr) {
|
||||
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: context.node.type, error: shapeErr });
|
||||
}
|
||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||
context.node.node_storage_path,
|
||||
accessToken || '',
|
||||
store,
|
||||
setLoadingState,
|
||||
undefined,
|
||||
editorRef.current || undefined
|
||||
);
|
||||
if (!cancelled) {
|
||||
// Repair store in case snapshot overwrote TLINSTANCE
|
||||
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
|
||||
setCanvasPhase('ready');
|
||||
}
|
||||
|
||||
setIsInitialLoad(false);
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Failed to place initial node', error);
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Failed to place initial node'
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
|
||||
}
|
||||
};
|
||||
|
||||
placeInitialNode();
|
||||
}, [context.node, store, isInitialLoad]);
|
||||
loadNodeSnapshot();
|
||||
return () => { cancelled = true; };
|
||||
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
|
||||
|
||||
// Handle navigation changes
|
||||
useEffect(() => {
|
||||
const handleNodeChange = async () => {
|
||||
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We can safely assert these types because we've checked for null above
|
||||
const editor = editorRef.current as Editor;
|
||||
const snapshotService = snapshotServiceRef.current;
|
||||
const currentNode = context.node;
|
||||
|
||||
try {
|
||||
setLoadingState({ status: 'loading', error: '' });
|
||||
logger.debug('single-player-page', '🔄 Loading node data', {
|
||||
nodeId: currentNode.id,
|
||||
node_storage_path: currentNode.node_storage_path,
|
||||
isInitialLoad
|
||||
});
|
||||
|
||||
// Get the previous node from navigation history
|
||||
const previousNode = context.history.currentIndex > 0
|
||||
? context.history.nodes[context.history.currentIndex - 1]
|
||||
: null;
|
||||
|
||||
// Handle navigation in snapshot service (load/save snapshot)
|
||||
await snapshotService.handleNavigationStart(previousNode, currentNode);
|
||||
|
||||
if (currentNode.type !== 'workspace') {
|
||||
try {
|
||||
const nodeData = await loadNodeData(currentNode);
|
||||
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
|
||||
} catch (shapeErr) {
|
||||
logger.warn('single-player-page', '⚠️ Could not place node shape', { type: currentNode.type, error: shapeErr });
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingState({ status: 'ready', error: '' });
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Failed to load node data', error);
|
||||
setLoadingState({
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Failed to load node data'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleNodeChange();
|
||||
}, [context.node, context.history, store]);
|
||||
// Handle navigation changes — save previous snapshot, load next one.
|
||||
// No automatic node shape placement: the canvas shows only persisted state.
|
||||
|
||||
// Inject auth and trigger initial context when token is ready
|
||||
useEffect(() => {
|
||||
@@ -485,7 +330,7 @@ export default function SinglePlayerPage() {
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
}}>
|
||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||
{(loadingState.status === 'loading' || !store) && (
|
||||
{(loadingState.status === 'loading' || !storeReady) && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -513,9 +358,15 @@ export default function SinglePlayerPage() {
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
{store && <Tldraw
|
||||
<ErrorBoundary fallback={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '1rem', padding: '2rem' }}>
|
||||
<p style={{ fontSize: '1.1rem' }}>Canvas failed to load.</p>
|
||||
<button onClick={() => window.location.reload()} style={{ padding: '0.5rem 1.25rem', cursor: 'pointer' }}>Reload</button>
|
||||
</div>
|
||||
}>
|
||||
{storeReady && storeRef.current && <Tldraw
|
||||
user={tldrawUser}
|
||||
store={store}
|
||||
store={storeRef.current}
|
||||
tools={singlePlayerTools}
|
||||
shapeUtils={allShapeUtils}
|
||||
bindingUtils={allBindingUtils}
|
||||
@@ -548,57 +399,17 @@ export default function SinglePlayerPage() {
|
||||
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
||||
}
|
||||
|
||||
setIsEditorReady(true);
|
||||
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
||||
editorId: editor.store.id,
|
||||
presentationMode,
|
||||
isEditorReady: true
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('single-player-page', '❌ Error in onMount', error);
|
||||
}
|
||||
}}
|
||||
/>}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to safely extract node_storage_path from different node structures
|
||||
const getNodeStoragePath = (node: NavigationNode): string | null => {
|
||||
// Try direct access first
|
||||
if (node.node_storage_path) {
|
||||
return node.node_storage_path;
|
||||
}
|
||||
|
||||
// Try nested under data
|
||||
if (node.data?.node_storage_path) {
|
||||
return node.data.node_storage_path;
|
||||
}
|
||||
|
||||
// Try other possible locations
|
||||
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
|
||||
return node.data.storage_path;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
|
||||
if (!node?.id) throw new Error('Node parameter is required');
|
||||
const nodeStoragePath = getNodeStoragePath(node);
|
||||
if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`);
|
||||
|
||||
const theme = getThemeFromLabel(node.type);
|
||||
return {
|
||||
title: node.label || node.type || '',
|
||||
w: 500,
|
||||
h: 350,
|
||||
state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null },
|
||||
headerColor: theme.headerColor,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
isLocked: false,
|
||||
__primarylabel__: node.type,
|
||||
uuid_string: node.id,
|
||||
node_storage_path: nodeStoragePath,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -85,6 +85,12 @@ const DashboardPage: React.FC = () => {
|
||||
>
|
||||
Open workspace
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate('/exam-marker')}
|
||||
>
|
||||
Exam Marker
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate('/calendar')}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* examRepository — the SINGLE module that talks to the /api/exam backend (spec R2.1 seam).
|
||||
*
|
||||
* All exam-marker persistence flows through here so a later dual-write / offline cache can slot
|
||||
* in without touching feature code. Mirrors the auth pattern of timetableService: take the
|
||||
* Supabase session JWT and send it as a Bearer token; the API enforces RLS as the user.
|
||||
*/
|
||||
import axios from 'axios';
|
||||
|
||||
import { API_BASE } from '../../config/apiConfig';
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import type {
|
||||
CreateTemplatePayload,
|
||||
ExamTemplate,
|
||||
ExamTemplateDetail,
|
||||
TemplateReplacePayload,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||
|
||||
async function authHeaders(): Promise<Record<string, string>> {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
return { Authorization: `Bearer ${session.access_token}` };
|
||||
}
|
||||
|
||||
export const examRepository = {
|
||||
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<{ templates: ExamTemplate[] }>(
|
||||
`${EXAM_BASE}/templates`,
|
||||
{ headers, params: { include_archived: includeArchived } },
|
||||
);
|
||||
return res.data.templates ?? [];
|
||||
},
|
||||
|
||||
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { headers, responseType: 'arraybuffer' });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async archiveTemplate(templateId: string): Promise<void> {
|
||||
const headers = await authHeaders();
|
||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
},
|
||||
};
|
||||
|
||||
export default examRepository;
|
||||
@@ -19,12 +19,17 @@ export class SharedStoreService {
|
||||
private lastSaveTime: number = Date.now();
|
||||
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
|
||||
private config: AutoSaveConfig;
|
||||
private isDirty = false;
|
||||
private dirtyListener: (() => void) | null = null;
|
||||
|
||||
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config
|
||||
};
|
||||
this.dirtyListener = store.listen(() => {
|
||||
this.isDirty = true;
|
||||
});
|
||||
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
|
||||
}
|
||||
|
||||
@@ -52,18 +57,16 @@ export class SharedStoreService {
|
||||
}
|
||||
|
||||
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
||||
const currentSnapshot = getSnapshot(this.store);
|
||||
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
|
||||
if (!this.isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!savedSnapshot || JSON.stringify(currentSnapshot) !== JSON.stringify(savedSnapshot)) {
|
||||
logger.debug('shared-store-service', '💾 Auto-saving snapshot - changes detected');
|
||||
await this.saveSnapshot(currentSnapshot, setLoadingState);
|
||||
this.lastSaveTime = now;
|
||||
} else {
|
||||
logger.trace('shared-store-service', '📝 No changes detected, skipping auto-save');
|
||||
}
|
||||
this.isDirty = false;
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
||||
await this.saveSnapshot(getSnapshot(this.store), setLoadingState);
|
||||
this.lastSaveTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +112,10 @@ export class SharedStoreService {
|
||||
|
||||
public clear(): void {
|
||||
this.stopAutoSave();
|
||||
this.dirtyListener?.();
|
||||
this.dirtyListener = null;
|
||||
this.store.clear();
|
||||
this.isDirty = false;
|
||||
logger.debug('shared-store-service', '🧹 Store cleared');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// External imports
|
||||
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
||||
import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
|
||||
import logger from '../../debugConfig';
|
||||
import { SharedStoreService } from './sharedStoreService';
|
||||
|
||||
@@ -109,17 +109,35 @@ export class NavigationSnapshotService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize session.currentPageId against pages actually in the document
|
||||
const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
|
||||
const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
|
||||
const session = snap.session as { currentPageId?: string } | undefined;
|
||||
if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) {
|
||||
logger.warn('snapshot-service', '⚠️ session.currentPageId not in snapshot pages — resetting to first page', {
|
||||
currentPageId: session.currentPageId, availablePages: pageIds
|
||||
});
|
||||
session.currentPageId = pageIds[0];
|
||||
}
|
||||
|
||||
const snapshotCopy = {
|
||||
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
||||
document: snap.document,
|
||||
session: snap.session,
|
||||
};
|
||||
|
||||
const targetStore = editor ? editor.store : store;
|
||||
try {
|
||||
if (editor) {
|
||||
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||
} else {
|
||||
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||
loadSnapshot(targetStore, snapshotCopy as any);
|
||||
// Post-load validation: ensure instance.currentPageId is still valid
|
||||
const instance = targetStore.get(TLINSTANCE_ID);
|
||||
if (instance) {
|
||||
const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page');
|
||||
const pageValid = pages.some((p) => p.id === instance.currentPageId);
|
||||
if (!pageValid) {
|
||||
logger.warn('snapshot-service', '⚠️ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId });
|
||||
targetStore.clear();
|
||||
}
|
||||
}
|
||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
||||
} catch (err) {
|
||||
@@ -128,7 +146,8 @@ export class NavigationSnapshotService {
|
||||
if (isSchemaMigration) {
|
||||
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
||||
} else {
|
||||
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg });
|
||||
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
|
||||
targetStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import '@testing-library/jest-dom';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
if (typeof globalThis.matchMedia !== 'function') {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
} as unknown as MediaQueryList))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* tailwind-compat.css
|
||||
* Minimal CSS shim replacing the Tailwind build pipeline.
|
||||
* Covers only the utility classes actually used in this codebase.
|
||||
* Generated from grep audit of 8 affected files (B3 Tailwind removal sprint).
|
||||
* Over time, replace these with --cc-* tokens or MUI sx props and remove entries.
|
||||
*/
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────────────── */
|
||||
.flex { display: flex; }
|
||||
.grid { display: grid; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
.inline-block { display: inline-block; }
|
||||
.block { display: block; }
|
||||
.hidden { display: none; }
|
||||
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.flex-1 { flex: 1 1 0%; }
|
||||
.flex-shrink-0 { flex-shrink: 0; }
|
||||
|
||||
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.items-end { align-items: flex-end; }
|
||||
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.justify-start { justify-content: flex-start; }
|
||||
|
||||
/* ── Position ───────────────────────────────────────────────────────────── */
|
||||
.relative { position: relative; }
|
||||
.absolute { position: absolute; }
|
||||
.fixed { position: fixed; }
|
||||
.sticky { position: sticky; }
|
||||
|
||||
/* ── Spacing — gap ──────────────────────────────────────────────────────── */
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.gap-6 { gap: 1.5rem; }
|
||||
|
||||
/* ── Spacing — padding ──────────────────────────────────────────────────── */
|
||||
.p-0 { padding: 0; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-5 { padding: 1.25rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.p-12 { padding: 3rem; }
|
||||
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
.px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||
|
||||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
|
||||
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
|
||||
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||
.py-12{ padding-top: 3rem; padding-bottom: 3rem; }
|
||||
.py-16{ padding-top: 4rem; padding-bottom: 4rem; }
|
||||
|
||||
.pt-4 { padding-top: 1rem; }
|
||||
.pl-10{ padding-left: 2.5rem; }
|
||||
.pr-4 { padding-right: 1rem; }
|
||||
|
||||
/* ── Spacing — margin ───────────────────────────────────────────────────── */
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mt-8 { margin-top: 2rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
|
||||
.ml-2 { margin-left: 0.5rem; }
|
||||
.ml-4 { margin-left: 1rem; }
|
||||
.mr-1 { margin-right: 0.25rem; }
|
||||
.mr-2 { margin-right: 0.5rem; }
|
||||
.my-8 { margin-top: 2rem; margin-bottom: 2rem; }
|
||||
|
||||
/* ── Space-between children ─────────────────────────────────────────────── */
|
||||
.space-x-2 > * + * { margin-left: 0.5rem; }
|
||||
.space-y-3 > * + * { margin-top: 0.75rem; }
|
||||
.space-y-4 > * + * { margin-top: 1rem; }
|
||||
.space-y-8 > * + * { margin-top: 2rem; }
|
||||
|
||||
/* ── Sizing ─────────────────────────────────────────────────────────────── */
|
||||
.w-0 { width: 0; }
|
||||
.w-4 { width: 1rem; }
|
||||
.w-8 { width: 2rem; }
|
||||
.w-10 { width: 2.5rem; }
|
||||
.w-12 { width: 3rem; }
|
||||
.w-16 { width: 4rem; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
.h-4 { height: 1rem; }
|
||||
.h-8 { height: 2rem; }
|
||||
.h-10 { height: 2.5rem; }
|
||||
.h-12 { height: 3rem; }
|
||||
.h-64 { height: 16rem; }
|
||||
.h-full { height: 100%; }
|
||||
|
||||
.min-h-screen { min-height: 100vh; }
|
||||
.max-w-md { max-width: 28rem; }
|
||||
.max-w-4xl { max-width: 56rem; }
|
||||
.max-w-6xl { max-width: 72rem; }
|
||||
.max-w-7xl { max-width: 80rem; }
|
||||
|
||||
/* ── Typography ─────────────────────────────────────────────────────────── */
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
|
||||
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.uppercase { text-transform: uppercase; }
|
||||
.capitalize { text-transform: capitalize; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ── Colours — text ─────────────────────────────────────────────────────── */
|
||||
.text-white { color: #ffffff; }
|
||||
.text-gray-300 { color: #d1d5db; }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
.text-gray-500 { color: #6b7280; }
|
||||
.text-gray-600 { color: #4b5563; }
|
||||
.text-gray-700 { color: #374151; }
|
||||
.text-gray-900 { color: #111827; }
|
||||
.text-blue-600 { color: #2563eb; }
|
||||
.text-blue-700 { color: #1d4ed8; }
|
||||
.text-red-600 { color: #dc2626; }
|
||||
.text-red-700 { color: #b91c1c; }
|
||||
.text-red-800 { color: #991b1b; }
|
||||
.text-green-600 { color: #16a34a; }
|
||||
.text-green-700 { color: #15803d; }
|
||||
.text-purple-600 { color: #9333ea; }
|
||||
.text-purple-700 { color: #7e22ce; }
|
||||
.text-yellow-700 { color: #a16207; }
|
||||
|
||||
/* ── Colours — background ───────────────────────────────────────────────── */
|
||||
.bg-white { background-color: #ffffff; }
|
||||
.bg-transparent { background-color: transparent; }
|
||||
.bg-gray-50 { background-color: #f9fafb; }
|
||||
.bg-gray-100 { background-color: #f3f4f6; }
|
||||
.bg-gray-500 { background-color: #6b7280; }
|
||||
.bg-blue-50 { background-color: #eff6ff; }
|
||||
.bg-blue-100 { background-color: #dbeafe; }
|
||||
.bg-blue-600 { background-color: #2563eb; }
|
||||
.bg-red-50 { background-color: #fef2f2; }
|
||||
.bg-red-100 { background-color: #fee2e2; }
|
||||
.bg-red-600 { background-color: #dc2626; }
|
||||
.bg-green-100 { background-color: #dcfce7; }
|
||||
.bg-purple-100 { background-color: #f3e8ff; }
|
||||
.bg-yellow-100 { background-color: #fef9c3; }
|
||||
|
||||
/* ── Borders ────────────────────────────────────────────────────────────── */
|
||||
.border { border-width: 1px; border-style: solid; }
|
||||
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
|
||||
.border-2 { border-width: 2px; border-style: solid; }
|
||||
.border-transparent { border-color: transparent; }
|
||||
.border-gray-100 { border-color: #f3f4f6; }
|
||||
.border-gray-200 { border-color: #e5e7eb; }
|
||||
.border-gray-300 { border-color: #d1d5db; }
|
||||
.border-blue-500 { border-color: #3b82f6; }
|
||||
.border-blue-600 { border-color: #2563eb; }
|
||||
.border-red-200 { border-color: #fecaca; }
|
||||
|
||||
/* ── Border radius ──────────────────────────────────────────────────────── */
|
||||
.rounded { border-radius: 0.25rem; }
|
||||
.rounded-md { border-radius: 0.375rem; }
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
.rounded-xl { border-radius: 0.75rem; }
|
||||
.rounded-full { border-radius: 9999px; }
|
||||
|
||||
/* ── Shadows ────────────────────────────────────────────────────────────── */
|
||||
.shadow { box-shadow: 0 1px 3px 0 rgb(0 0 0/.1),0 1px 2px -1px rgb(0 0 0/.1); }
|
||||
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0/.05); }
|
||||
.shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0/.1),0 2px 4px -2px rgb(0 0 0/.1); }
|
||||
.shadow-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0/.1),0 4px 6px -4px rgb(0 0 0/.1); }
|
||||
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0/.1),0 8px 10px -6px rgb(0 0 0/.1); }
|
||||
|
||||
/* ── Overflow / z-index ─────────────────────────────────────────────────── */
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
.z-50 { z-index: 50; }
|
||||
|
||||
/* ── Misc ───────────────────────────────────────────────────────────────── */
|
||||
.opacity-50 { opacity: 0.5; }
|
||||
.opacity-75 { opacity: 0.75; }
|
||||
.cursor-not-allowed { cursor: not-allowed; }
|
||||
|
||||
/* ── Pseudo-classes ─────────────────────────────────────────────────────── */
|
||||
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
|
||||
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
|
||||
.hover\:bg-blue-200:hover { background-color: #bfdbfe; }
|
||||
.hover\:bg-blue-700:hover { background-color: #1d4ed8; }
|
||||
.hover\:bg-green-200:hover { background-color: #bbf7d0; }
|
||||
.hover\:bg-red-200:hover { background-color: #fecaca; }
|
||||
.hover\:bg-red-700:hover { background-color: #b91c1c; }
|
||||
.hover\:text-gray-500:hover { color: #6b7280; }
|
||||
.hover\:text-gray-700:hover { color: #374151; }
|
||||
.hover\:text-gray-800:hover { color: #1f2937; }
|
||||
.hover\:text-blue-600:hover { color: #2563eb; }
|
||||
.hover\:text-red-800:hover { color: #991b1b; }
|
||||
.hover\:border-blue-300:hover { border-color: #93c5fd; }
|
||||
|
||||
.focus\:outline-none:focus { outline: none; }
|
||||
.focus\:ring-2:focus { box-shadow: 0 0 0 2px #3b82f6; }
|
||||
|
||||
.disabled\:opacity-50:disabled { opacity: 0.5; }
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Exam-marker types — mirror the FastAPI /api/exam contract (see api routers/exam/schemas.py).
|
||||
* Supabase is source of truth for this operational data; the graph (cc.public.exams) joins by
|
||||
* the shared UUIDs (template/question/region ids, exam_code).
|
||||
*/
|
||||
|
||||
export type ExamTemplateStatus = 'draft' | 'ready' | 'archived';
|
||||
|
||||
export interface ExamTemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string | null;
|
||||
exam_id: string | null;
|
||||
exam_code: string | null;
|
||||
source_file_id: string | null;
|
||||
page_count: number | null;
|
||||
institute_id: string;
|
||||
teacher_id: string;
|
||||
status: ExamTemplateStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplatePayload {
|
||||
title: string;
|
||||
subject?: string;
|
||||
exam_id?: string;
|
||||
exam_code?: string;
|
||||
source_file_id?: string;
|
||||
page_count?: number;
|
||||
institute_id?: string;
|
||||
}
|
||||
|
||||
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||
export interface ExamQuestion {
|
||||
id: string;
|
||||
template_id: string;
|
||||
parent_id: string | null;
|
||||
label: string;
|
||||
order: number;
|
||||
max_marks: number;
|
||||
answer_type: string | null;
|
||||
mcq_options: unknown | null;
|
||||
mark_scheme: Record<string, unknown>;
|
||||
is_container: boolean;
|
||||
spec_ref: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
}
|
||||
|
||||
export interface ExamResponseArea {
|
||||
id: string;
|
||||
question_id: string;
|
||||
template_id: string;
|
||||
page: number;
|
||||
bounds: Record<string, number>;
|
||||
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
|
||||
response_form: string | null;
|
||||
context_type?: string | null;
|
||||
source: 'manual' | 'ai';
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
export interface ExamBoundary {
|
||||
id: string;
|
||||
template_id: string;
|
||||
question_id: string | null;
|
||||
label: string | null;
|
||||
page_index: number;
|
||||
y: number;
|
||||
bounds: Record<string, number> | null;
|
||||
source: 'manual' | 'ai';
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
export interface ExamTemplateDetail extends ExamTemplate {
|
||||
questions: ExamQuestion[];
|
||||
response_areas: ExamResponseArea[];
|
||||
boundaries: ExamBoundary[];
|
||||
}
|
||||
|
||||
|
||||
export interface TemplateReplacePayload {
|
||||
meta?: {
|
||||
title?: string;
|
||||
subject?: string;
|
||||
page_count?: number;
|
||||
status?: ExamTemplateStatus;
|
||||
};
|
||||
questions: Array<{
|
||||
id?: string;
|
||||
parent_id?: string | null;
|
||||
label: string;
|
||||
order?: number;
|
||||
max_marks?: number;
|
||||
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||
mcq_options?: unknown | null;
|
||||
mark_scheme?: Record<string, unknown>;
|
||||
is_container?: boolean;
|
||||
spec_ref?: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
}>;
|
||||
response_areas: Array<{
|
||||
id?: string;
|
||||
question_id: string;
|
||||
page: number;
|
||||
bounds: Record<string, number>;
|
||||
kind: ExamResponseArea['kind'];
|
||||
response_form?: string | null;
|
||||
context_type?: string | null;
|
||||
source?: 'manual' | 'ai';
|
||||
confirmed?: boolean;
|
||||
confidence?: number | null;
|
||||
}>;
|
||||
boundaries: Array<{
|
||||
id?: string;
|
||||
question_id?: string | null;
|
||||
label?: string | null;
|
||||
page_index: number;
|
||||
y: number;
|
||||
bounds?: Record<string, number> | null;
|
||||
source?: 'manual' | 'ai';
|
||||
confirmed?: boolean;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExamTemplateDetail } from '../../types/exam.types'
|
||||
import { serializeCanvasShapes, shapesFromTemplate, type PdfPageLayout } from './model'
|
||||
|
||||
const template: ExamTemplateDetail = {
|
||||
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
|
||||
}
|
||||
|
||||
const ids = {
|
||||
top: '11111111-1111-4111-8111-111111111111',
|
||||
bottom: '22222222-2222-4222-8222-222222222222',
|
||||
main: '33333333-3333-4333-8333-333333333333',
|
||||
part: '44444444-4444-4444-8444-444444444444',
|
||||
response: '55555555-5555-4555-8555-555555555555',
|
||||
}
|
||||
|
||||
describe('exam setup canvas serialization', () => {
|
||||
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
|
||||
const payload = serializeCanvasShapes(template, [
|
||||
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
||||
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
||||
{ id: ids.part, kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
|
||||
{ id: ids.response, kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
|
||||
])
|
||||
|
||||
const main = payload.questions.find((q) => q.is_container)
|
||||
const part = payload.questions.find((q) => q.id === ids.part)
|
||||
expect(main?.label).toBe('Q1')
|
||||
expect(part?.parent_id).toBe(main?.id)
|
||||
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
|
||||
expect(payload.response_areas[0]).toMatchObject({ id: ids.response, question_id: ids.part, kind: 'response', response_form: 'lines' })
|
||||
expect(payload.boundaries).toHaveLength(2)
|
||||
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses real PDF page layouts to save visual page numbers', () => {
|
||||
const layouts: PdfPageLayout[] = [
|
||||
{ page: 1, x: 0, y: 0, w: 780, h: 1000 },
|
||||
{ page: 2, x: 0, y: 1040, w: 780, h: 980 },
|
||||
]
|
||||
const payload = serializeCanvasShapes(template, [
|
||||
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 1060, w: 700, h: 8, label: 'Q2 start' },
|
||||
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 1800, w: 700, h: 8, label: 'Q2 end' },
|
||||
{ id: ids.part, kind: 'part', x: 100, y: 1120, w: 400, h: 220, label: 'Q2(a)', maxMarks: 3 },
|
||||
{ id: ids.response, kind: 'response', x: 130, y: 1250, w: 300, h: 90, responseForm: 'lines' },
|
||||
], layouts)
|
||||
|
||||
expect(payload.boundaries.map((b) => b.page_index)).toEqual([1, 1])
|
||||
expect(payload.questions.find((q) => q.id === ids.part)?.page).toBe(2)
|
||||
expect(payload.response_areas[0].page).toBe(2)
|
||||
expect(payload.meta?.page_count).toBe(2)
|
||||
})
|
||||
|
||||
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
|
||||
const shapes = shapesFromTemplate({
|
||||
...template,
|
||||
questions: [
|
||||
{ id: ids.main, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
|
||||
{ id: ids.part, template_id: 'tpl-1', parent_id: ids.main, label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
|
||||
],
|
||||
response_areas: [
|
||||
{ id: ids.response, question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
|
||||
{ id: '66666666-6666-4666-8666-666666666666', question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
|
||||
],
|
||||
boundaries: [{ id: ids.top, template_id: 'tpl-1', question_id: ids.main, label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
||||
})
|
||||
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: ids.part, x: 1, y: 2, w: 3, h: 4 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
|
||||
|
||||
export const PAGE_WIDTH = 780
|
||||
export const DEFAULT_PAGE_HEIGHT = 1100
|
||||
export const PAGE_GAP = 40
|
||||
// Back-compat alias for S4-9a callers/tests. Real PDF pages use PdfPageLayout heights.
|
||||
export const PAGE_HEIGHT = DEFAULT_PAGE_HEIGHT + PAGE_GAP
|
||||
|
||||
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||
|
||||
export interface CanvasBounds { x: number; y: number; w: number; h: number }
|
||||
export interface PdfPageLayout { page: number; x: number; y: number; w: number; h: number }
|
||||
|
||||
export interface ExamCanvasShapeModel {
|
||||
id: string
|
||||
kind: ExamCanvasShapeKind
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
label?: string
|
||||
maxMarks?: number
|
||||
answerType?: 'written' | 'mcq' | 'short' | 'diagram'
|
||||
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
||||
contextType?: string
|
||||
questionId?: string | null
|
||||
}
|
||||
|
||||
export function fallbackPageLayouts(pageCount = 4): PdfPageLayout[] {
|
||||
return Array.from({ length: Math.max(pageCount, 1) }, (_, i) => ({
|
||||
page: i + 1,
|
||||
x: 0,
|
||||
y: i * PAGE_HEIGHT,
|
||||
w: PAGE_WIDTH,
|
||||
h: DEFAULT_PAGE_HEIGHT,
|
||||
}))
|
||||
}
|
||||
|
||||
export function pageForY(y: number, layouts?: PdfPageLayout[]): number {
|
||||
if (!layouts?.length) return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
||||
const hit = layouts.find((layout) => y >= layout.y && y <= layout.y + layout.h)
|
||||
if (hit) return hit.page
|
||||
const nearest = layouts.reduce((best, layout) => {
|
||||
const dist = y < layout.y ? layout.y - y : y - (layout.y + layout.h)
|
||||
return dist < best.dist ? { page: layout.page, dist } : best
|
||||
}, { page: layouts[0].page, dist: Number.POSITIVE_INFINITY })
|
||||
return nearest.page
|
||||
}
|
||||
|
||||
export function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, layouts?: PdfPageLayout[]): number {
|
||||
return pageForY(shape.y + shape.h / 2, layouts)
|
||||
}
|
||||
|
||||
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
|
||||
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
||||
}
|
||||
|
||||
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
||||
const ox2 = outer.x + outer.w
|
||||
const oy2 = outer.y + outer.h
|
||||
const ix2 = inner.x + inner.w
|
||||
const iy2 = inner.y + inner.h
|
||||
return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2
|
||||
}
|
||||
|
||||
function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean {
|
||||
const minY = Math.min(top.y, bottom.y)
|
||||
const maxY = Math.max(top.y, bottom.y)
|
||||
const cy = shape.y + shape.h / 2
|
||||
return cy >= minY && cy <= maxY
|
||||
}
|
||||
|
||||
function uuid(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
||||
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
||||
)
|
||||
}
|
||||
|
||||
function stableUuid(id?: string | null): string {
|
||||
return id && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ? id : uuid()
|
||||
}
|
||||
|
||||
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pageLayouts?: PdfPageLayout[]): TemplateReplacePayload {
|
||||
const orderedBoundaries = shapes
|
||||
.filter((s) => s.kind === 'boundary')
|
||||
.sort((a, b) => (pageForShape(a, pageLayouts) - pageForShape(b, pageLayouts)) || (a.y - b.y))
|
||||
const parts = shapes.filter((s) => s.kind === 'part')
|
||||
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
||||
|
||||
const questions: TemplateReplacePayload['questions'] = []
|
||||
const boundaries: TemplateReplacePayload['boundaries'] = []
|
||||
const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = []
|
||||
|
||||
for (let i = 0; i < orderedBoundaries.length; i += 2) {
|
||||
const top = orderedBoundaries[i]
|
||||
const bottom = orderedBoundaries[i + 1]
|
||||
if (!top || !bottom) break
|
||||
const qNum = bands.length + 1
|
||||
const questionId = stableUuid(top.questionId || bottom.questionId)
|
||||
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
||||
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
||||
bands.push({ questionId, top, bottom })
|
||||
for (const b of [top, bottom]) {
|
||||
boundaries.push({ id: stableUuid(b.id), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pageLayouts) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
||||
}
|
||||
}
|
||||
|
||||
const partQuestionIds = new Map<string, string>()
|
||||
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
|
||||
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||
const qid = stableUuid(part.questionId || part.id)
|
||||
partQuestionIds.set(part.id, qid)
|
||||
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pageLayouts) })
|
||||
})
|
||||
|
||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||
for (const region of regions) {
|
||||
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||
const regionPage = pageForShape(region, pageLayouts)
|
||||
const fallbackPart = parts.find((part) => pageForShape(part, pageLayouts) === regionPage) ?? parts[0]
|
||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||
if (!questionId) continue
|
||||
const kind = region.kind as ExamCanvasRegionKind
|
||||
response_areas.push({ id: stableUuid(region.id), question_id: questionId, page: regionPage, bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
||||
}
|
||||
|
||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: pageLayouts?.length ?? template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
||||
}
|
||||
|
||||
export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] {
|
||||
const shapes: ExamCanvasShapeModel[] = []
|
||||
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||
for (const b of detail.boundaries ?? []) {
|
||||
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
|
||||
shapes.push({ id: stableUuid(b.id), kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
|
||||
}
|
||||
for (const q of detail.questions ?? []) {
|
||||
if (q.is_container || !q.bounds) continue
|
||||
shapes.push({ id: stableUuid(q.id), kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
||||
}
|
||||
for (const r of detail.response_areas ?? []) {
|
||||
const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 }
|
||||
const q = questions.get(r.question_id)
|
||||
shapes.push({ id: stableUuid(r.id), kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
||||
}
|
||||
return shapes
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
|
||||
import { CCBaseShape } from './cc-types'
|
||||
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
|
||||
import { logger } from '../../../debugConfig'
|
||||
|
||||
export interface ToolbarItem {
|
||||
@@ -40,44 +39,32 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
|
||||
|
||||
component(shape: T) {
|
||||
const {
|
||||
props: { w, h, isLocked },
|
||||
props: { w, h, isLocked, headerColor },
|
||||
} = shape
|
||||
const toolbarItems = this.getToolbarItems(shape)
|
||||
|
||||
return (
|
||||
<HTMLContainer
|
||||
id={shape.id}
|
||||
className="cc-shape-container"
|
||||
style={{
|
||||
width: toDomPrecision(w),
|
||||
height: toDomPrecision(h),
|
||||
backgroundColor: shape.props.headerColor,
|
||||
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
|
||||
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
'--shape-header-color': headerColor,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: shape.props.headerColor,
|
||||
padding: CC_BASE_STYLE_CONSTANTS.HEADER.padding,
|
||||
height: CC_BASE_STYLE_CONSTANTS.HEADER.height,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: isLocked ? 'not-allowed' : 'move',
|
||||
pointerEvents: 'all',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
className="cc-shape-header"
|
||||
style={{ cursor: isLocked ? 'not-allowed' : 'move' }}
|
||||
>
|
||||
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
|
||||
<span className="shape-title">{shape.props.title}</span>
|
||||
<div className="cc-shape-toolbar">
|
||||
{toolbarItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
title={item.label}
|
||||
style={{ opacity: item.isActive ? 1 : 0.7 }}
|
||||
onClick={(e) => {
|
||||
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
|
||||
e.preventDefault()
|
||||
@@ -89,46 +76,19 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
opacity: item.isActive ? 1 : 0.7,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'all',
|
||||
fontSize: '16px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
zIndex: 100,
|
||||
userSelect: 'none',
|
||||
position: 'relative',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ pointerEvents: 'none' }}>
|
||||
{item.icon}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
|
||||
{isLocked && <span style={{ color: 'var(--cc-text-inverse)' }}>🔒</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: CC_BASE_STYLE_CONSTANTS.HEADER.height,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'auto',
|
||||
padding: CC_BASE_STYLE_CONSTANTS.CONTENT.padding,
|
||||
backgroundColor: shape.props.backgroundColor,
|
||||
}}
|
||||
className="cc-shape-content"
|
||||
style={{ backgroundColor: shape.props.backgroundColor }}
|
||||
>
|
||||
{this.renderContent(shape)}
|
||||
</div>
|
||||
|
||||
@@ -17,10 +17,16 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
|
||||
|
||||
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
|
||||
const defaultProps = getDefaultCCTimetableLessonNodeProps()
|
||||
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
|
||||
return {
|
||||
...defaultProps,
|
||||
headerColor: theme.headerColor,
|
||||
headerColor: '',
|
||||
school_db_name: '',
|
||||
school_period_id: '',
|
||||
subject_class: '',
|
||||
date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
period_code: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -543,6 +543,8 @@ export const getDefaultCCTimetableLessonNodeProps = () => ({
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
period_code: '',
|
||||
school_db_name: '',
|
||||
school_period_id: '',
|
||||
})
|
||||
|
||||
export const getDefaultCCPlannedLessonNodeProps = () => ({
|
||||
|
||||
@@ -22,7 +22,6 @@ import { CCSubjectNodeShape, CCSubjectNodeShapeUtil } from './CCSubjectNodeShape
|
||||
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
|
||||
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
|
||||
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
|
||||
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
|
||||
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
|
||||
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
|
||||
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
|
||||
@@ -31,12 +30,11 @@ import { CCAcademicDayNodeShape, CCAcademicDayNodeShapeUtil } from './CCAcademic
|
||||
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
|
||||
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
|
||||
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
|
||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
||||
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
|
||||
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
|
||||
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
|
||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
||||
|
||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCUserTimetableLessonNodeShapeUtil'
|
||||
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
|
||||
// Create a const object with all node types
|
||||
export const NODE_SHAPE_TYPES = {
|
||||
USER: CCUserNodeShapeUtil.type,
|
||||
@@ -118,8 +116,7 @@ export type AllNodeShapes =
|
||||
| CCTimetableLessonNodeShape
|
||||
| CCPlannedLessonNodeShape
|
||||
| CCDepartmentStructureNodeShape
|
||||
| CCUserTeacherTimetableNodeShape
|
||||
| CCTimetableLessonNodeShape;
|
||||
| CCUserTeacherTimetableNodeShape;
|
||||
|
||||
// Export all shape utils in an object for easy access
|
||||
export const ShapeUtils = {
|
||||
@@ -159,7 +156,6 @@ export const ShapeUtils = {
|
||||
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
|
||||
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
|
||||
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
|
||||
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
|
||||
} as const;
|
||||
|
||||
// Add a type guard to check if a shape is a valid node shape
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Unit tests for CCLiveTranscriptionShapeUtil
|
||||
*
|
||||
* Intended production location:
|
||||
* src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts
|
||||
*
|
||||
* Run from classroom-copilot-app root:
|
||||
* npx vitest run transcription-shape
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { Editor, createShapeId, createTLStore } from '@tldraw/tldraw'
|
||||
import { CCLiveTranscriptionShapeUtil, type CCLiveTranscriptionShape, type TranscriptionSegment } from './CCLiveTranscriptionShapeUtil'
|
||||
import { CCSlideShapeUtil } from '../cc-slideshow/CCSlideShapeUtil'
|
||||
import { CCSlideShowShapeUtil } from '../cc-slideshow/CCSlideShowShapeUtil'
|
||||
import { getDefaultCCLiveTranscriptionProps } from '../cc-props'
|
||||
|
||||
// ─── Mock TranscriptionManager ──────────────────────────────────────────────
|
||||
// Must be hoisted before the module under test is imported in a real project.
|
||||
// Here we patch the module directly since we control the import order.
|
||||
|
||||
// Declare symbols before vi.mock so hoisting/factory capture stays consistent.
|
||||
const mockStartTranscription = () => {}
|
||||
const mockStopTranscription = () => {}
|
||||
|
||||
vi.mock('./TranscriptionManager', () => {
|
||||
const start = vi.fn()
|
||||
const stop = vi.fn()
|
||||
Object.assign(globalThis, { __mockStartTranscription: start, __mockStopTranscription: stop })
|
||||
return {
|
||||
TranscriptionManager: {
|
||||
getManager: vi.fn().mockReturnValue({ startTranscription: start, stopTranscription: stop }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const sm = () => (globalThis as unknown as Record<string, any>).__mockStartTranscription
|
||||
const st = () => (globalThis as unknown as Record<string, any>).__mockStopTranscription
|
||||
|
||||
const clearMocks = () => { sm().mockClear(); st().mockClear() }
|
||||
|
||||
// Provide a minimal matchMedia so tldraw's UserPreferencesManager
|
||||
// can call addEventListener/removeEventListener in jsdom.
|
||||
if (typeof globalThis.matchMedia !== 'function') {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
} as unknown as MediaQueryList))
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal shape utils needed to boot the Editor for transcription tests. */
|
||||
const shapeUtils = [CCLiveTranscriptionShapeUtil, CCSlideShapeUtil, CCSlideShowShapeUtil]
|
||||
|
||||
function makeEditor(): Editor {
|
||||
const store = createTLStore({ shapeUtils, bindingUtils: [] })
|
||||
return new Editor({
|
||||
shapeUtils,
|
||||
bindingUtils: [],
|
||||
tools: [],
|
||||
store,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
}
|
||||
|
||||
/** Create a transcription shape with optional prop overrides and return its id. */
|
||||
function createTranscriptionShape(
|
||||
editor: Editor,
|
||||
overrides: Partial<CCLiveTranscriptionShape['props']> = {}
|
||||
) {
|
||||
const id = createShapeId()
|
||||
editor.createShape<CCLiveTranscriptionShape>({
|
||||
id,
|
||||
type: 'cc-live-transcription',
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: {
|
||||
...getDefaultCCLiveTranscriptionProps(),
|
||||
...overrides,
|
||||
} as CCLiveTranscriptionShape['props'],
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/** Typed getter for the shape record. */
|
||||
function getShape(editor: Editor, id: ReturnType<typeof createShapeId>) {
|
||||
return editor.getShape<CCLiveTranscriptionShape>(id)!
|
||||
}
|
||||
|
||||
/** Build a finished TranscriptionSegment fixture. */
|
||||
function makeSegment(text: string, overrides: Partial<TranscriptionSegment> = {}): TranscriptionSegment {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
completed: true,
|
||||
start: '0.000',
|
||||
end: '1.000',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test Suite ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('CCLiveTranscriptionShapeUtil', () => {
|
||||
let editor: Editor
|
||||
let util: CCLiveTranscriptionShapeUtil
|
||||
|
||||
beforeEach(() => {
|
||||
editor = makeEditor()
|
||||
util = editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription')
|
||||
clearMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor.dispose()
|
||||
})
|
||||
|
||||
// ── getDefaultProps ─────────────────────────────────────────────────────
|
||||
|
||||
describe('getDefaultProps()', () => {
|
||||
it('returns the expected default structure', () => {
|
||||
const defaults = util.getDefaultProps()
|
||||
|
||||
expect(defaults).toMatchObject({
|
||||
isRecording: false,
|
||||
segments: [],
|
||||
currentSegment: undefined,
|
||||
lastProcessedSegment: undefined,
|
||||
})
|
||||
// Base props present
|
||||
expect(typeof defaults.title).toBe('string')
|
||||
expect(typeof defaults.w).toBe('number')
|
||||
expect(typeof defaults.h).toBe('number')
|
||||
expect(typeof defaults.headerColor).toBe('string')
|
||||
expect(typeof defaults.backgroundColor).toBe('string')
|
||||
expect(defaults.isLocked).toBe(false)
|
||||
})
|
||||
|
||||
it('segments array is empty, not null or undefined', () => {
|
||||
const { segments } = util.getDefaultProps()
|
||||
expect(Array.isArray(segments)).toBe(true)
|
||||
expect(segments).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── updateText — unconfirmed (live partial) ─────────────────────────────
|
||||
|
||||
describe('updateText() — unconfirmed segment (isConfirmed = false)', () => {
|
||||
it('updates only currentSegment, does not touch the segments array', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 })
|
||||
|
||||
const shape = getShape(editor, id)
|
||||
expect(shape.props.segments).toHaveLength(0)
|
||||
expect(shape.props.currentSegment).toMatchObject({
|
||||
text: 'hello wor',
|
||||
completed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('overwrites currentSegment when called again with new partial text', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 })
|
||||
util.updateText(id, 'hello world', false, { start: 0, end: 1.2 })
|
||||
|
||||
const shape = getShape(editor, id)
|
||||
expect(shape.props.currentSegment?.text).toBe('hello world')
|
||||
expect(shape.props.segments).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does NOT call updateShape when the partial text is unchanged', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
currentSegment: makeSegment('same text', { completed: false }),
|
||||
})
|
||||
|
||||
const spy = vi.spyOn(editor, 'updateShape')
|
||||
util.updateText(id, 'same text', false, { start: 0, end: 1.0 })
|
||||
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ── updateText — confirmed (completed) segment ─────────────────────────
|
||||
|
||||
describe('updateText() — confirmed segment (isConfirmed = true)', () => {
|
||||
it('appends to segments array when text is new', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'First sentence.', true, { start: 0, end: 2.1 })
|
||||
|
||||
const shape = getShape(editor, id)
|
||||
expect(shape.props.segments).toHaveLength(1)
|
||||
expect(shape.props.segments[0]).toMatchObject({
|
||||
text: 'First sentence.',
|
||||
completed: true,
|
||||
start: '0.000',
|
||||
end: '2.100',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates lastProcessedSegment to the confirmed text', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'Second sentence.', true, { start: 2, end: 4 })
|
||||
|
||||
expect(getShape(editor, id).props.lastProcessedSegment).toBe('Second sentence.')
|
||||
})
|
||||
|
||||
it('clears currentSegment when its text matches the confirmed text', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
currentSegment: makeSegment('Final words.', { completed: false }),
|
||||
})
|
||||
|
||||
util.updateText(id, 'Final words.', true, { start: 5, end: 7 })
|
||||
|
||||
expect(getShape(editor, id).props.currentSegment).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves currentSegment when its text differs from the confirmed text', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
currentSegment: makeSegment('still typing...', { completed: false }),
|
||||
})
|
||||
|
||||
util.updateText(id, 'Earlier sentence confirmed.', true, { start: 0, end: 3 })
|
||||
|
||||
expect(getShape(editor, id).props.currentSegment?.text).toBe('still typing...')
|
||||
})
|
||||
|
||||
it('DEDUPLICATION — does not add a segment whose text is already in segments[]', () => {
|
||||
const existingSegment = makeSegment('Duplicate text.')
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
segments: [existingSegment],
|
||||
lastProcessedSegment: 'Duplicate text.',
|
||||
})
|
||||
|
||||
util.updateText(id, 'Duplicate text.', true, { start: 10, end: 12 })
|
||||
|
||||
const shape = getShape(editor, id)
|
||||
expect(shape.props.segments).toHaveLength(1)
|
||||
expect(shape.props.segments[0].id).toBe(existingSegment.id)
|
||||
})
|
||||
|
||||
it('DEDUPLICATION — blocks re-add even when lastProcessedSegment differs (isDuplicate text check)', () => {
|
||||
const existingSegment = makeSegment('Already confirmed.')
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
segments: [existingSegment],
|
||||
lastProcessedSegment: undefined,
|
||||
})
|
||||
|
||||
util.updateText(id, 'Already confirmed.', true, { start: 15, end: 17 })
|
||||
|
||||
expect(getShape(editor, id).props.segments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accumulates multiple unique confirmed segments in order', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'Sentence one.', true, { start: 0, end: 2 })
|
||||
util.updateText(id, 'Sentence two.', true, { start: 2, end: 4 })
|
||||
util.updateText(id, 'Sentence three.', true, { start: 4, end: 6 })
|
||||
|
||||
const { segments } = getShape(editor, id).props
|
||||
expect(segments).toHaveLength(3)
|
||||
expect(segments.map((s) => s.text)).toEqual([
|
||||
'Sentence one.',
|
||||
'Sentence two.',
|
||||
'Sentence three.',
|
||||
])
|
||||
})
|
||||
|
||||
it('assigns unique ids to each confirmed segment', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'Alpha.', true, { start: 0, end: 1 })
|
||||
util.updateText(id, 'Beta.', true, { start: 1, end: 2 })
|
||||
|
||||
const { segments } = getShape(editor, id).props
|
||||
expect(segments[0].id).not.toBe(segments[1].id)
|
||||
})
|
||||
|
||||
it('formats numeric start/end timestamps to 3 decimal places', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
util.updateText(id, 'Timestamped.', true, { start: 1.1, end: 2.5678 })
|
||||
|
||||
const seg = getShape(editor, id).props.segments[0]
|
||||
expect(seg.start).toBe('1.100')
|
||||
expect(seg.end).toBe('2.568')
|
||||
})
|
||||
})
|
||||
|
||||
// ── toggleRecording ────────────────────────────────────────────────────
|
||||
|
||||
describe('toggleRecording() — via private accessor', () => {
|
||||
// TypeScript private methods are still callable at runtime; we use
|
||||
// bracket notation to bypass the access check cleanly in tests.
|
||||
const toggle = (u: CCLiveTranscriptionShapeUtil, shape: CCLiveTranscriptionShape) =>
|
||||
(u as unknown as { toggleRecording(s: CCLiveTranscriptionShape): void }).toggleRecording(shape)
|
||||
|
||||
it('STARTING — sets isRecording to true', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||
const shape = getShape(editor, id)
|
||||
|
||||
toggle(util, shape)
|
||||
|
||||
expect(getShape(editor, id).props.isRecording).toBe(true)
|
||||
})
|
||||
|
||||
it('STARTING — resets segments to an empty array', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: false,
|
||||
segments: [makeSegment('old transcript'), makeSegment('more old text')],
|
||||
})
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
expect(getShape(editor, id).props.segments).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('STARTING — clears currentSegment and lastProcessedSegment', () => {
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: false,
|
||||
currentSegment: makeSegment('partial', { completed: false }),
|
||||
lastProcessedSegment: 'some prior segment',
|
||||
})
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
const props = getShape(editor, id).props
|
||||
expect(props.currentSegment).toBeUndefined()
|
||||
expect(props.lastProcessedSegment).toBeUndefined()
|
||||
})
|
||||
|
||||
it('STARTING — calls TranscriptionManager.startTranscription with the shape id', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||
const shape = getShape(editor, id)
|
||||
|
||||
toggle(util, shape)
|
||||
|
||||
expect(sm()).toHaveBeenCalledOnce()
|
||||
expect(sm()).toHaveBeenCalledWith(id)
|
||||
})
|
||||
|
||||
it('STOPPING — sets isRecording to false', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
const shape = getShape(editor, id)
|
||||
|
||||
toggle(util, shape)
|
||||
|
||||
expect(getShape(editor, id).props.isRecording).toBe(false)
|
||||
})
|
||||
|
||||
it('STOPPING — preserves existing segments (does not wipe them)', () => {
|
||||
const seg1 = makeSegment('Keep me.')
|
||||
const seg2 = makeSegment('Keep me too.')
|
||||
const id = createTranscriptionShape(editor, {
|
||||
isRecording: true,
|
||||
segments: [seg1, seg2],
|
||||
})
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
const { segments } = getShape(editor, id).props
|
||||
expect(segments).toHaveLength(2)
|
||||
expect(segments[0].text).toBe('Keep me.')
|
||||
expect(segments[1].text).toBe('Keep me too.')
|
||||
})
|
||||
|
||||
it('STOPPING — calls TranscriptionManager.stopTranscription', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
expect(st()).toHaveBeenCalledOnce()
|
||||
expect(sm()).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('start-stop cycle leaves shape in consistent state', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: false })
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
expect(getShape(editor, id).props.isRecording).toBe(true)
|
||||
|
||||
util.updateText(id, 'Live lesson note.', true, { start: 0, end: 3 })
|
||||
|
||||
toggle(util, getShape(editor, id))
|
||||
|
||||
const props = getShape(editor, id).props
|
||||
expect(props.isRecording).toBe(false)
|
||||
expect(props.segments).toHaveLength(1)
|
||||
expect(props.segments[0].text).toBe('Live lesson note.')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Edge cases ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateText() — edge cases', () => {
|
||||
it('does nothing if the shape id does not exist in the store', () => {
|
||||
const ghostId = createShapeId('nonexistent')
|
||||
expect(() => util.updateText(ghostId, 'text', true, { start: 0, end: 1 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles empty string text without crashing', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
expect(() => util.updateText(id, '', true, { start: 0, end: 0 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles string start/end metadata (passed as already-formatted strings)', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
util.updateText(id, 'String timestamps.', true, { start: '1.234' as unknown as number, end: '5.678' as unknown as number })
|
||||
|
||||
const seg = getShape(editor, id).props.segments[0]
|
||||
expect(typeof seg.start).toBe('string')
|
||||
expect(typeof seg.end).toBe('string')
|
||||
})
|
||||
|
||||
it('handles missing metadata gracefully, falling back to "0.000"', () => {
|
||||
const id = createTranscriptionShape(editor, { isRecording: true })
|
||||
util.updateText(id, 'No timestamps.', true, undefined as unknown as { start: number; end: number })
|
||||
|
||||
const seg = getShape(editor, id).props.segments[0]
|
||||
expect(seg.start).toBe('0.000')
|
||||
expect(seg.end).toBe('0.000')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { TranscriptionManager } from '../cc-transcription/TranscriptionManager';
|
||||
import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props';
|
||||
import { ccShapeMigrations } from '../cc-migrations';
|
||||
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
|
||||
import { logger } from '../../../../debugConfig';
|
||||
|
||||
export interface TranscriptionSegment {
|
||||
id: string
|
||||
@@ -83,7 +84,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
backgroundColor: isRecording ? '#f44336' : '#4CAF50',
|
||||
backgroundColor: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)',
|
||||
color: 'white',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
@@ -105,7 +106,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: isRecording ? '#f44336' : '#4CAF50'
|
||||
color: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)'
|
||||
}}>
|
||||
{isRecording ? 'Recording...' : 'Ready'}
|
||||
</div>
|
||||
@@ -207,11 +208,11 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
}
|
||||
|
||||
private toggleRecording(shape: CCLiveTranscriptionShape) {
|
||||
console.log('🎤 Toggle recording clicked');
|
||||
logger.debug('cc-live-transcription', '🎤 Toggle recording clicked');
|
||||
const { id } = shape;
|
||||
const { isRecording } = shape.props;
|
||||
|
||||
console.log('Current state:', { id, isRecording });
|
||||
logger.debug('cc-live-transcription', 'Current state', { id, isRecording });
|
||||
|
||||
// When starting new recording, preserve existing props but reset segments
|
||||
const newProps = !isRecording ? {
|
||||
@@ -232,13 +233,13 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
});
|
||||
|
||||
const manager = TranscriptionManager.getManager(this.editor);
|
||||
console.log('Got transcription manager');
|
||||
logger.debug('cc-live-transcription', 'Got transcription manager');
|
||||
|
||||
if (!isRecording) {
|
||||
console.log('Starting transcription...');
|
||||
logger.debug('cc-live-transcription', 'Starting transcription...');
|
||||
manager.startTranscription(id);
|
||||
} else {
|
||||
console.log('Stopping transcription...');
|
||||
logger.debug('cc-live-transcription', 'Stopping transcription...');
|
||||
manager.stopTranscription();
|
||||
}
|
||||
}
|
||||
@@ -249,10 +250,10 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
isConfirmed: boolean,
|
||||
metadata?: { start: string | number, end: string | number }
|
||||
) {
|
||||
console.log('📝 Updating text:', { id, text, isConfirmed, metadata });
|
||||
logger.debug('cc-live-transcription', '📝 Updating text', { id, text, isConfirmed, metadata });
|
||||
const shape = this.editor.getShape<CCLiveTranscriptionShape>(id);
|
||||
if (!shape) {
|
||||
console.warn('❌ Shape not found for updating text:', id);
|
||||
logger.warn('cc-live-transcription', '❌ Shape not found for updating text', id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,7 +309,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Text updated');
|
||||
logger.debug('cc-live-transcription', '✅ Text updated');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ export class TranscriptionManager {
|
||||
this.transcriptionService = new TranscriptionService();
|
||||
|
||||
// Set up callback for transcription updates
|
||||
this.transcriptionService.setTranscriptionCallback((text: string, isFinal: boolean, metadata: { start: number, end: number }) => {
|
||||
console.log('📝 Transcription update received:', { text, metadata });
|
||||
this.transcriptionService.setServerSegmentsCallback((segments: Array<{ text: string; start: number; end: number }>, isLastLive: boolean) => {
|
||||
console.log('📝 Transcription update received:', { segments: segments.length, isLastLive });
|
||||
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
|
||||
if (!util) {
|
||||
console.warn('❌ Shape util not found');
|
||||
@@ -36,6 +36,10 @@ export class TranscriptionManager {
|
||||
}
|
||||
console.log('Found transcription util:', !!util);
|
||||
|
||||
const text = segments.map(s => s.text).join('') || '';
|
||||
const metadata = segments.length ? { start: segments[0].start, end: segments[segments.length - 1].end } : { start: 0, end: 0 };
|
||||
const isFinal = isLastLive;
|
||||
|
||||
// Check if text is stable (same output multiple times)
|
||||
const isStable = text === this.lastText;
|
||||
if (isStable) {
|
||||
|
||||
@@ -18,40 +18,40 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
|
||||
overflowY: 'auto',
|
||||
maxHeight: '100%',
|
||||
minWidth: '200px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderLeft: '1px solid #ddd',
|
||||
backgroundColor: 'var(--cc-navy-50)',
|
||||
borderLeft: '1px solid var(--cc-border)',
|
||||
},
|
||||
title: {
|
||||
margin: '0 0 10px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
color: 'var(--cc-text-primary)',
|
||||
},
|
||||
line: {
|
||||
padding: '5px',
|
||||
marginBottom: '5px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #eee',
|
||||
color: 'var(--cc-text-primary)',
|
||||
backgroundColor: 'var(--cc-bg-canvas)',
|
||||
border: '1px solid var(--cc-border-strong)',
|
||||
},
|
||||
activeLine: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
border: '1px solid #2196f3',
|
||||
color: '#1565c0',
|
||||
backgroundColor: 'var(--cc-navy-50)',
|
||||
border: '1px solid var(--cc-action-primary)',
|
||||
color: 'var(--cc-action-primary)',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
},
|
||||
timestamp: {
|
||||
color: '#666',
|
||||
color: 'var(--cc-text-tertiary)',
|
||||
marginRight: '8px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
loading: {
|
||||
color: '#666',
|
||||
color: 'var(--cc-text-muted)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
},
|
||||
@@ -64,14 +64,14 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
|
||||
},
|
||||
button: {
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '1px solid #ddd',
|
||||
backgroundColor: 'var(--cc-navy-50)',
|
||||
border: '1px solid var(--cc-border)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
color: '#333',
|
||||
color: 'var(--cc-text-primary)',
|
||||
fontSize: '14px',
|
||||
'&:hover': {
|
||||
backgroundColor: '#e0e0e0',
|
||||
backgroundColor: 'var(--cc-border-strong)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,976 @@
|
||||
/* ============================================================
|
||||
CLASSROOM COPILOT — DESIGN SYSTEM
|
||||
Aesthetic Direction: Technical Editorial / Industrial Utilitarian
|
||||
|
||||
Philosophy: Drafting table discipline. Every element earns
|
||||
its place. Tone contrast over shadow depth. Grid over flow.
|
||||
============================================================ */
|
||||
|
||||
/* ─── TYPOGRAPHY IMPORTS ───────────────────────────────────── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
/* ─── ROOT TOKENS — LIGHT MODE ─────────────────────────────── */
|
||||
:root {
|
||||
/* Typography */
|
||||
--cc-font-sans: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
--cc-font-mono: 'IBM Plex Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
--cc-text-xs: 0.6875rem; /* 11px */
|
||||
--cc-text-sm: 0.75rem; /* 12px */
|
||||
--cc-text-base: 0.875rem; /* 14px */
|
||||
--cc-text-md: 1rem; /* 16px */
|
||||
--cc-text-lg: 1.125rem; /* 18px */
|
||||
--cc-text-xl: 1.25rem; /* 20px */
|
||||
|
||||
--cc-weight-normal: 400;
|
||||
--cc-weight-medium: 500;
|
||||
--cc-weight-semibold: 600;
|
||||
|
||||
--cc-leading-tight: 1.2;
|
||||
--cc-leading-normal: 1.5;
|
||||
--cc-leading-loose: 1.7;
|
||||
|
||||
--cc-tracking-tight: -0.01em;
|
||||
--cc-tracking-normal: 0;
|
||||
--cc-tracking-wide: 0.04em;
|
||||
--cc-tracking-widest: 0.12em;
|
||||
|
||||
/* ── Color Primitives ── */
|
||||
--cc-navy-950: #0D1117;
|
||||
--cc-navy-900: #14191F;
|
||||
--cc-navy-800: #1C2330;
|
||||
--cc-navy-700: #263040;
|
||||
--cc-navy-600: #344256;
|
||||
--cc-navy-500: #435470;
|
||||
--cc-navy-400: #5A6E8A;
|
||||
--cc-navy-300: #7A8FA8;
|
||||
--cc-navy-200: #A4B4C8;
|
||||
--cc-navy-100: #D2DCE8;
|
||||
--cc-navy-50: #EBF0F5;
|
||||
|
||||
--cc-parchment-950: #2A2519;
|
||||
--cc-parchment-900: #3D3626;
|
||||
--cc-parchment-800: #5C5238;
|
||||
--cc-parchment-700: #7A6E4E;
|
||||
--cc-parchment-600: #9A8C6A;
|
||||
--cc-parchment-500: #B8A882;
|
||||
--cc-parchment-400: #CEC0A0;
|
||||
--cc-parchment-300: #DED2BA;
|
||||
--cc-parchment-200: #EDE5D4;
|
||||
--cc-parchment-100: #F5F2ED;
|
||||
--cc-parchment-50: #FAF8F5;
|
||||
|
||||
--cc-amber-700: #8A5C1A;
|
||||
--cc-amber-600: #A8711F;
|
||||
--cc-amber-500: #C9882A;
|
||||
--cc-amber-400: #D9A24E;
|
||||
--cc-amber-300: #E6BC78;
|
||||
--cc-amber-200: #F0D4A8;
|
||||
--cc-amber-100: #F8EDDA;
|
||||
|
||||
--cc-sage-700: #2E3E36;
|
||||
--cc-sage-600: #3A5046;
|
||||
--cc-sage-500: #4A5E52;
|
||||
--cc-sage-400: #607A6C;
|
||||
--cc-sage-300: #7E9688;
|
||||
--cc-sage-200: #A8BDB4;
|
||||
--cc-sage-100: #D2DDD9;
|
||||
|
||||
--cc-crimson-600: #8B1A1A;
|
||||
--cc-crimson-500: #A82828;
|
||||
--cc-crimson-400: #C44040;
|
||||
--cc-crimson-100: #F5DADA;
|
||||
|
||||
--cc-signal-green-600: #1A5C36;
|
||||
--cc-signal-green-500: #246B42;
|
||||
--cc-signal-green-400: #347A52;
|
||||
--cc-signal-green-100: #D4EDE0;
|
||||
|
||||
/* ── Semantic Color Assignments — Light ── */
|
||||
|
||||
/* Surfaces */
|
||||
--cc-bg-canvas: var(--cc-parchment-100);
|
||||
--cc-bg-panel: var(--cc-parchment-50);
|
||||
--cc-bg-panel-alt: #FFFFFF;
|
||||
--cc-bg-shape: #FFFFFF;
|
||||
--cc-bg-shape-hover: var(--cc-parchment-100);
|
||||
--cc-bg-overlay: rgba(13, 17, 23, 0.48);
|
||||
--cc-bg-code: var(--cc-navy-900);
|
||||
|
||||
/* Shape / card header tones */
|
||||
--cc-header-default: var(--cc-navy-700);
|
||||
--cc-header-accent: var(--cc-amber-500);
|
||||
--cc-header-sage: var(--cc-sage-600);
|
||||
--cc-header-dim: var(--cc-navy-500);
|
||||
|
||||
/* Text */
|
||||
--cc-text-primary: var(--cc-navy-900);
|
||||
--cc-text-secondary: var(--cc-navy-500);
|
||||
--cc-text-tertiary: var(--cc-navy-300);
|
||||
--cc-text-inverse: var(--cc-parchment-100);
|
||||
--cc-text-accent: var(--cc-amber-500);
|
||||
--cc-text-code: var(--cc-parchment-200);
|
||||
--cc-text-link: var(--cc-amber-600);
|
||||
|
||||
/* Borders */
|
||||
--cc-border: var(--cc-navy-100);
|
||||
--cc-border-strong: var(--cc-navy-200);
|
||||
--cc-border-accent: var(--cc-amber-500);
|
||||
--cc-border-focus: var(--cc-amber-400);
|
||||
--cc-border-error: var(--cc-crimson-500);
|
||||
--cc-border-success: var(--cc-signal-green-500);
|
||||
|
||||
/* Interactive */
|
||||
--cc-action-primary: var(--cc-amber-500);
|
||||
--cc-action-primary-hover: var(--cc-amber-400);
|
||||
--cc-action-primary-active: var(--cc-amber-600);
|
||||
--cc-action-secondary: var(--cc-navy-700);
|
||||
--cc-action-secondary-hover: var(--cc-navy-600);
|
||||
--cc-action-danger: var(--cc-crimson-500);
|
||||
--cc-action-success: var(--cc-signal-green-500);
|
||||
|
||||
/* States */
|
||||
--cc-selected: var(--cc-amber-100);
|
||||
--cc-hover: var(--cc-parchment-200);
|
||||
--cc-active: var(--cc-parchment-300);
|
||||
--cc-disabled: var(--cc-navy-100);
|
||||
|
||||
/* Recording / live indicator */
|
||||
--cc-recording-active: var(--cc-crimson-500);
|
||||
--cc-recording-idle: var(--cc-signal-green-500);
|
||||
|
||||
/* ── Geometry ── */
|
||||
--cc-radius-none: 0;
|
||||
--cc-radius-xs: 1px;
|
||||
--cc-radius-sm: 2px; /* maximum for shapes */
|
||||
--cc-radius-md: 4px; /* panels, menus */
|
||||
--cc-radius-full: 9999px;
|
||||
|
||||
/* ── Spacing scale (8pt grid) ── */
|
||||
--cc-space-0: 0;
|
||||
--cc-space-px: 1px;
|
||||
--cc-space-0_5: 2px;
|
||||
--cc-space-1: 4px;
|
||||
--cc-space-2: 8px;
|
||||
--cc-space-3: 12px;
|
||||
--cc-space-4: 16px;
|
||||
--cc-space-5: 20px;
|
||||
--cc-space-6: 24px;
|
||||
--cc-space-8: 32px;
|
||||
--cc-space-10: 40px;
|
||||
--cc-space-12: 48px;
|
||||
--cc-space-16: 64px;
|
||||
|
||||
/* ── Shape dimensions ── */
|
||||
--cc-shape-header-h: 32px;
|
||||
--cc-shape-header-pad: var(--cc-space-2);
|
||||
--cc-shape-content-pad: var(--cc-space-2);
|
||||
--cc-shape-min-w: 120px;
|
||||
--cc-shape-min-h: 80px;
|
||||
|
||||
/* ── Panel dimensions ── */
|
||||
--cc-panel-w: 280px;
|
||||
--cc-panel-header-h: 44px;
|
||||
--cc-panel-handle-w: 20px;
|
||||
|
||||
/* ── Borders ── */
|
||||
--cc-border-width: 1px;
|
||||
--cc-border-width-thick: 2px;
|
||||
|
||||
/* No blurry box shadows. Depth through tone. */
|
||||
--cc-shadow-none: none;
|
||||
--cc-shadow-edge: 1px 0 0 0 var(--cc-border);
|
||||
--cc-shadow-crisp: 2px 2px 0 0 var(--cc-navy-200);
|
||||
--cc-shadow-raised: 3px 3px 0 0 var(--cc-navy-200);
|
||||
|
||||
/* ── Z-index system ── */
|
||||
--cc-z-canvas: 0;
|
||||
--cc-z-shape: 1;
|
||||
--cc-z-panel: 200;
|
||||
--cc-z-toolbar: 300;
|
||||
--cc-z-menu: 400;
|
||||
--cc-z-modal: 500;
|
||||
--cc-z-toast: 600;
|
||||
|
||||
/* ── Motion ── */
|
||||
--cc-transition-snap: 80ms linear;
|
||||
--cc-transition-quick: 120ms ease-out;
|
||||
--cc-transition-base: 200ms ease-out;
|
||||
--cc-transition-slow: 320ms ease-out;
|
||||
}
|
||||
|
||||
/* ─── DARK MODE TOKENS ──────────────────────────────────────── */
|
||||
[data-color-mode="dark"] {
|
||||
/* Surfaces */
|
||||
--cc-bg-canvas: var(--cc-navy-950);
|
||||
--cc-bg-panel: var(--cc-navy-900);
|
||||
--cc-bg-panel-alt: var(--cc-navy-800);
|
||||
--cc-bg-shape: var(--cc-navy-800);
|
||||
--cc-bg-shape-hover: var(--cc-navy-700);
|
||||
--cc-bg-overlay: rgba(13, 17, 23, 0.72);
|
||||
--cc-bg-code: var(--cc-navy-950);
|
||||
|
||||
/* Shape headers get slightly brighter in dark */
|
||||
--cc-header-default: var(--cc-navy-600);
|
||||
--cc-header-accent: var(--cc-amber-600);
|
||||
--cc-header-sage: var(--cc-sage-500);
|
||||
--cc-header-dim: var(--cc-navy-700);
|
||||
|
||||
/* Text */
|
||||
--cc-text-primary: var(--cc-parchment-100);
|
||||
--cc-text-secondary: var(--cc-navy-200);
|
||||
--cc-text-tertiary: var(--cc-navy-300);
|
||||
--cc-text-inverse: var(--cc-navy-900);
|
||||
--cc-text-accent: var(--cc-amber-400);
|
||||
--cc-text-code: var(--cc-parchment-300);
|
||||
--cc-text-link: var(--cc-amber-400);
|
||||
|
||||
/* Borders */
|
||||
--cc-border: var(--cc-navy-700);
|
||||
--cc-border-strong: var(--cc-navy-600);
|
||||
--cc-border-accent: var(--cc-amber-500);
|
||||
--cc-border-focus: var(--cc-amber-300);
|
||||
--cc-border-error: var(--cc-crimson-400);
|
||||
--cc-border-success: var(--cc-signal-green-400);
|
||||
|
||||
/* Interactive */
|
||||
--cc-action-primary: var(--cc-amber-500);
|
||||
--cc-action-primary-hover: var(--cc-amber-300);
|
||||
--cc-action-primary-active: var(--cc-amber-600);
|
||||
--cc-action-secondary: var(--cc-navy-400);
|
||||
--cc-action-secondary-hover: var(--cc-navy-300);
|
||||
|
||||
/* States */
|
||||
--cc-selected: rgba(201, 136, 42, 0.15);
|
||||
--cc-hover: var(--cc-navy-800);
|
||||
--cc-active: var(--cc-navy-700);
|
||||
--cc-disabled: var(--cc-navy-700);
|
||||
|
||||
/* Shadows become tone dividers in dark */
|
||||
--cc-shadow-edge: 1px 0 0 0 var(--cc-navy-700);
|
||||
--cc-shadow-crisp: 2px 2px 0 0 var(--cc-navy-950);
|
||||
--cc-shadow-raised: 3px 3px 0 0 var(--cc-navy-950);
|
||||
}
|
||||
|
||||
/* ─── BASE RESET (minimal — respects tldraw's own base) ────── */
|
||||
.cc-root * {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--cc-font-sans);
|
||||
}
|
||||
|
||||
.cc-root code,
|
||||
.cc-root pre,
|
||||
.cc-mono {
|
||||
font-family: var(--cc-font-mono);
|
||||
}
|
||||
|
||||
/* ─── SHAPE CARD SYSTEM ─────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
.cc-shape-container
|
||||
└── .cc-shape-header
|
||||
└── .cc-shape-content
|
||||
*/
|
||||
|
||||
.cc-shape-container {
|
||||
display: grid;
|
||||
grid-template-rows: var(--cc-shape-header-h) 1fr;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--cc-bg-shape);
|
||||
border: var(--cc-border-width) solid var(--cc-border);
|
||||
border-radius: var(--cc-radius-sm);
|
||||
overflow: hidden;
|
||||
/* Flat depth — no blur */
|
||||
box-shadow: var(--cc-shadow-crisp);
|
||||
transition: box-shadow var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-shape-container:hover {
|
||||
box-shadow: var(--cc-shadow-raised);
|
||||
}
|
||||
|
||||
.cc-shape-container--locked {
|
||||
opacity: 0.72;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Header strip — the primary identification band */
|
||||
.cc-shape-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
padding: 0 var(--cc-shape-header-pad);
|
||||
height: var(--cc-shape-header-h);
|
||||
background: var(--cc-header-default);
|
||||
color: var(--cc-text-inverse);
|
||||
/* Hairline rule below header, not shadow */
|
||||
border-bottom: var(--cc-border-width) solid rgba(255,255,255,0.12);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Accent variant headers */
|
||||
.cc-shape-header--accent { background: var(--cc-header-accent); color: var(--cc-navy-900); }
|
||||
.cc-shape-header--sage { background: var(--cc-header-sage); }
|
||||
.cc-shape-header--dim { background: var(--cc-header-dim); }
|
||||
|
||||
.cc-shape-header__title {
|
||||
font-size: var(--cc-text-sm);
|
||||
font-weight: var(--cc-weight-semibold);
|
||||
letter-spacing: var(--cc-tracking-wide);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cc-shape-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cc-space-1);
|
||||
}
|
||||
|
||||
/* Toolbar buttons inside shape headers */
|
||||
.cc-shape-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--cc-radius-xs);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
touch-action: none;
|
||||
transition:
|
||||
opacity var(--cc-transition-snap),
|
||||
background var(--cc-transition-snap);
|
||||
}
|
||||
|
||||
.cc-shape-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.cc-shape-btn:active {
|
||||
background: rgba(255,255,255,0.25);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.cc-shape-btn--active {
|
||||
opacity: 1;
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.cc-shape-content {
|
||||
padding: var(--cc-shape-content-pad);
|
||||
overflow: auto;
|
||||
background: var(--cc-bg-shape);
|
||||
color: var(--cc-text-primary);
|
||||
font-size: var(--cc-text-base);
|
||||
line-height: var(--cc-leading-normal);
|
||||
/* Hide scrollbar visually, keep functionality */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cc-border-strong) transparent;
|
||||
}
|
||||
|
||||
.cc-shape-content::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.cc-shape-content::-webkit-scrollbar-thumb {
|
||||
background: var(--cc-border-strong);
|
||||
border-radius: var(--cc-radius-xs);
|
||||
}
|
||||
|
||||
.cc-shape-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ─── PANEL SYSTEM ──────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
.cc-panel
|
||||
└── .cc-panel-header
|
||||
└── .cc-panel-content
|
||||
*/
|
||||
|
||||
.cc-panel {
|
||||
display: grid;
|
||||
grid-template-rows: var(--cc-panel-header-h) 1fr;
|
||||
width: var(--cc-panel-w);
|
||||
height: 100%;
|
||||
background: var(--cc-bg-panel);
|
||||
border-right: var(--cc-border-width) solid var(--cc-border);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: var(--cc-z-panel);
|
||||
}
|
||||
|
||||
.cc-panel-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--cc-space-2);
|
||||
padding: 0 var(--cc-space-2);
|
||||
height: var(--cc-panel-header-h);
|
||||
background: var(--cc-bg-panel);
|
||||
border-bottom: var(--cc-border-width) solid var(--cc-border-strong);
|
||||
/* Left accent stripe — the editorial mark */
|
||||
box-shadow: inset 2px 0 0 0 var(--cc-border-accent);
|
||||
}
|
||||
|
||||
.cc-panel-content {
|
||||
overflow-y: auto;
|
||||
padding: var(--cc-space-2);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cc-border) transparent;
|
||||
}
|
||||
|
||||
.cc-panel-content::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.cc-panel-content::-webkit-scrollbar-thumb {
|
||||
background: var(--cc-border-strong);
|
||||
}
|
||||
|
||||
/* Panel tab / type selector */
|
||||
.cc-panel-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cc-space-1);
|
||||
padding: var(--cc-space-1) var(--cc-space-2);
|
||||
background: transparent;
|
||||
border: var(--cc-border-width) solid var(--cc-border);
|
||||
border-radius: var(--cc-radius-sm);
|
||||
color: var(--cc-text-primary);
|
||||
font-family: var(--cc-font-sans);
|
||||
font-size: var(--cc-text-sm);
|
||||
font-weight: var(--cc-weight-medium);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--cc-transition-quick),
|
||||
background var(--cc-transition-quick);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cc-panel-selector:hover {
|
||||
border-color: var(--cc-border-strong);
|
||||
background: var(--cc-hover);
|
||||
}
|
||||
|
||||
.cc-panel-selector__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cc-panel-selector__chevron {
|
||||
margin-left: auto;
|
||||
opacity: 0.5;
|
||||
transition: transform var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-panel-selector[aria-expanded="true"] .cc-panel-selector__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Panel collapse handle */
|
||||
.cc-panel-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--cc-panel-handle-w);
|
||||
height: 40px;
|
||||
background: var(--cc-bg-panel);
|
||||
border: var(--cc-border-width) solid var(--cc-border);
|
||||
border-left: none;
|
||||
border-radius: 0 var(--cc-radius-md) var(--cc-radius-md) 0;
|
||||
color: var(--cc-text-tertiary);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
z-index: var(--cc-z-panel);
|
||||
transition:
|
||||
color var(--cc-transition-quick),
|
||||
background var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-panel-handle:hover {
|
||||
background: var(--cc-hover);
|
||||
color: var(--cc-text-primary);
|
||||
}
|
||||
|
||||
/* Pin button */
|
||||
.cc-pin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--cc-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--cc-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--cc-transition-quick),
|
||||
background var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-pin-btn:hover {
|
||||
background: var(--cc-hover);
|
||||
color: var(--cc-text-primary);
|
||||
}
|
||||
|
||||
.cc-pin-btn--active {
|
||||
color: var(--cc-action-primary);
|
||||
}
|
||||
|
||||
/* ─── PANEL MENU / DROPDOWN ─────────────────────────────────── */
|
||||
|
||||
.cc-menu {
|
||||
position: absolute;
|
||||
background: var(--cc-bg-panel-alt);
|
||||
border: var(--cc-border-width) solid var(--cc-border-strong);
|
||||
border-radius: var(--cc-radius-md);
|
||||
padding: var(--cc-space-1) 0;
|
||||
min-width: 200px;
|
||||
z-index: var(--cc-z-menu);
|
||||
/* Hard-edged drop — no blur */
|
||||
box-shadow: 4px 4px 0 0 var(--cc-navy-200);
|
||||
}
|
||||
|
||||
[data-color-mode="dark"] .cc-menu {
|
||||
box-shadow: 4px 4px 0 0 var(--cc-navy-950);
|
||||
}
|
||||
|
||||
.cc-menu-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
align-items: start;
|
||||
gap: var(--cc-space-2);
|
||||
padding: var(--cc-space-2) var(--cc-space-3);
|
||||
color: var(--cc-text-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-family: var(--cc-font-sans);
|
||||
font-size: var(--cc-text-sm);
|
||||
transition: background var(--cc-transition-snap);
|
||||
}
|
||||
|
||||
.cc-menu-item:hover {
|
||||
background: var(--cc-hover);
|
||||
}
|
||||
|
||||
.cc-menu-item--selected {
|
||||
background: var(--cc-selected);
|
||||
color: var(--cc-text-accent);
|
||||
}
|
||||
|
||||
.cc-menu-item__icon {
|
||||
color: var(--cc-text-secondary);
|
||||
transition: color var(--cc-transition-snap);
|
||||
}
|
||||
|
||||
.cc-menu-item:hover .cc-menu-item__icon,
|
||||
.cc-menu-item--selected .cc-menu-item__icon {
|
||||
color: var(--cc-action-primary);
|
||||
}
|
||||
|
||||
.cc-menu-item__label {
|
||||
font-weight: var(--cc-weight-medium);
|
||||
line-height: var(--cc-leading-tight);
|
||||
}
|
||||
|
||||
.cc-menu-item__desc {
|
||||
font-size: var(--cc-text-xs);
|
||||
color: var(--cc-text-secondary);
|
||||
line-height: var(--cc-leading-normal);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cc-menu-divider {
|
||||
height: var(--cc-border-width);
|
||||
background: var(--cc-border);
|
||||
margin: var(--cc-space-1) 0;
|
||||
}
|
||||
|
||||
/* ─── BUTTONS ────────────────────────────────────────────────── */
|
||||
|
||||
.cc-btn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: var(--cc-space-1) var(--cc-space-3);
|
||||
border: var(--cc-border-width) solid transparent;
|
||||
border-radius: var(--cc-radius-sm);
|
||||
font-family: var(--cc-font-sans);
|
||||
font-size: var(--cc-text-sm);
|
||||
font-weight: var(--cc-weight-medium);
|
||||
letter-spacing: var(--cc-tracking-wide);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background var(--cc-transition-quick),
|
||||
border-color var(--cc-transition-quick),
|
||||
transform var(--cc-transition-snap),
|
||||
box-shadow var(--cc-transition-snap);
|
||||
}
|
||||
|
||||
.cc-btn:active {
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
.cc-btn--primary {
|
||||
background: var(--cc-action-primary);
|
||||
color: var(--cc-navy-900);
|
||||
border-color: var(--cc-amber-600);
|
||||
box-shadow: 2px 2px 0 0 var(--cc-amber-700);
|
||||
}
|
||||
|
||||
.cc-btn--primary:hover {
|
||||
background: var(--cc-action-primary-hover);
|
||||
box-shadow: 3px 3px 0 0 var(--cc-amber-700);
|
||||
}
|
||||
|
||||
.cc-btn--primary:active {
|
||||
box-shadow: none;
|
||||
background: var(--cc-action-primary-active);
|
||||
}
|
||||
|
||||
.cc-btn--secondary {
|
||||
background: transparent;
|
||||
color: var(--cc-text-primary);
|
||||
border-color: var(--cc-border-strong);
|
||||
}
|
||||
|
||||
.cc-btn--secondary:hover {
|
||||
background: var(--cc-hover);
|
||||
border-color: var(--cc-navy-300);
|
||||
}
|
||||
|
||||
.cc-btn--danger {
|
||||
background: transparent;
|
||||
color: var(--cc-action-danger);
|
||||
border-color: var(--cc-border-error);
|
||||
}
|
||||
|
||||
.cc-btn--danger:hover {
|
||||
background: var(--cc-crimson-100);
|
||||
}
|
||||
|
||||
.cc-btn--icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: var(--cc-radius-sm);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--cc-text-secondary);
|
||||
}
|
||||
|
||||
.cc-btn--icon:hover {
|
||||
background: var(--cc-hover);
|
||||
color: var(--cc-text-primary);
|
||||
}
|
||||
|
||||
/* ─── INPUT / FORM ELEMENTS ─────────────────────────────────── */
|
||||
|
||||
.cc-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--cc-space-1) var(--cc-space-2);
|
||||
background: var(--cc-bg-panel-alt);
|
||||
border: var(--cc-border-width) solid var(--cc-border-strong);
|
||||
border-radius: var(--cc-radius-sm);
|
||||
color: var(--cc-text-primary);
|
||||
font-family: var(--cc-font-sans);
|
||||
font-size: var(--cc-text-base);
|
||||
line-height: var(--cc-leading-normal);
|
||||
transition: border-color var(--cc-transition-quick);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cc-input::placeholder {
|
||||
color: var(--cc-text-tertiary);
|
||||
}
|
||||
|
||||
.cc-input:focus {
|
||||
border-color: var(--cc-border-focus);
|
||||
box-shadow: 0 0 0 2px rgba(201,136,42,0.18);
|
||||
}
|
||||
|
||||
.cc-input:disabled {
|
||||
background: var(--cc-disabled);
|
||||
color: var(--cc-text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── RECORDING / LIVE INDICATOR ────────────────────────────── */
|
||||
|
||||
.cc-record-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
transition:
|
||||
background var(--cc-transition-quick),
|
||||
transform var(--cc-transition-snap),
|
||||
box-shadow var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-record-btn--idle {
|
||||
background: var(--cc-recording-idle);
|
||||
color: #fff;
|
||||
box-shadow: 2px 2px 0 0 var(--cc-signal-green-600);
|
||||
}
|
||||
|
||||
.cc-record-btn--idle:hover {
|
||||
background: var(--cc-signal-green-400);
|
||||
box-shadow: 3px 3px 0 0 var(--cc-signal-green-600);
|
||||
}
|
||||
|
||||
.cc-record-btn--active {
|
||||
background: var(--cc-recording-active);
|
||||
color: #fff;
|
||||
box-shadow: 2px 2px 0 0 var(--cc-crimson-600);
|
||||
/* Pulsing border — CSS-only, no JS */
|
||||
animation: cc-record-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cc-record-pulse {
|
||||
0%,100% { box-shadow: 2px 2px 0 0 var(--cc-crimson-600), 0 0 0 0 rgba(168,40,40,0.4); }
|
||||
50% { box-shadow: 2px 2px 0 0 var(--cc-crimson-600), 0 0 0 6px rgba(168,40,40,0); }
|
||||
}
|
||||
|
||||
.cc-record-btn--active:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* Status text accompanying record button */
|
||||
.cc-record-status {
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: var(--cc-text-sm);
|
||||
font-weight: var(--cc-weight-medium);
|
||||
letter-spacing: var(--cc-tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cc-record-status--idle { color: var(--cc-recording-idle); }
|
||||
.cc-record-status--active { color: var(--cc-recording-active); }
|
||||
|
||||
/* ─── TRANSCRIPT SEGMENTS ───────────────────────────────────── */
|
||||
|
||||
.cc-transcript-segment {
|
||||
padding: var(--cc-space-2);
|
||||
border-left: 2px solid var(--cc-border);
|
||||
margin-bottom: var(--cc-space-1);
|
||||
transition: border-color var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-transcript-segment:hover {
|
||||
border-color: var(--cc-border-accent);
|
||||
}
|
||||
|
||||
.cc-transcript-segment--current {
|
||||
border-color: var(--cc-action-primary);
|
||||
background: var(--cc-selected);
|
||||
}
|
||||
|
||||
.cc-transcript-timestamp {
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: var(--cc-text-xs);
|
||||
color: var(--cc-text-tertiary);
|
||||
letter-spacing: var(--cc-tracking-wide);
|
||||
margin-bottom: var(--cc-space-0_5);
|
||||
}
|
||||
|
||||
.cc-transcript-text {
|
||||
font-size: var(--cc-text-base);
|
||||
color: var(--cc-text-primary);
|
||||
line-height: var(--cc-leading-normal);
|
||||
}
|
||||
|
||||
/* ─── GRAPH NODE CARDS ──────────────────────────────────────── */
|
||||
|
||||
.cc-graph-node {
|
||||
display: grid;
|
||||
grid-template-rows: var(--cc-shape-header-h) 1fr;
|
||||
border: var(--cc-border-width) solid var(--cc-border);
|
||||
border-radius: var(--cc-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--cc-bg-shape);
|
||||
box-shadow: var(--cc-shadow-crisp);
|
||||
transition: box-shadow var(--cc-transition-quick);
|
||||
}
|
||||
|
||||
.cc-graph-node:hover {
|
||||
box-shadow: var(--cc-shadow-raised);
|
||||
}
|
||||
|
||||
/* Node type label pill */
|
||||
.cc-node-type-tag {
|
||||
display: inline-block;
|
||||
padding: 1px var(--cc-space-1);
|
||||
border: var(--cc-border-width) solid currentColor;
|
||||
border-radius: var(--cc-radius-xs);
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: var(--cc-text-xs);
|
||||
font-weight: var(--cc-weight-medium);
|
||||
letter-spacing: var(--cc-tracking-widest);
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ─── SECTION / DIVIDER TYPOGRAPHY ─────────────────────────── */
|
||||
|
||||
.cc-section-label {
|
||||
font-size: var(--cc-text-xs);
|
||||
font-weight: var(--cc-weight-semibold);
|
||||
letter-spacing: var(--cc-tracking-widest);
|
||||
text-transform: uppercase;
|
||||
color: var(--cc-text-tertiary);
|
||||
padding-bottom: var(--cc-space-1);
|
||||
border-bottom: var(--cc-border-width) solid var(--cc-border);
|
||||
margin-bottom: var(--cc-space-2);
|
||||
}
|
||||
|
||||
.cc-divider {
|
||||
height: var(--cc-border-width);
|
||||
background: var(--cc-border);
|
||||
margin: var(--cc-space-2) 0;
|
||||
}
|
||||
|
||||
/* ─── LOADING STATES ─────────────────────────────────────────── */
|
||||
|
||||
.cc-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--cc-border-strong);
|
||||
border-top-color: var(--cc-action-primary);
|
||||
border-radius: 50%;
|
||||
animation: cc-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cc-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.cc-skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--cc-border) 25%,
|
||||
var(--cc-hover) 50%,
|
||||
var(--cc-border) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: cc-shimmer 1.4s ease-in-out infinite;
|
||||
border-radius: var(--cc-radius-xs);
|
||||
}
|
||||
|
||||
@keyframes cc-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ─── TOASTS / ALERTS ────────────────────────────────────────── */
|
||||
|
||||
.cc-alert {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: var(--cc-space-2);
|
||||
padding: var(--cc-space-2) var(--cc-space-3);
|
||||
border: var(--cc-border-width) solid;
|
||||
border-radius: var(--cc-radius-sm);
|
||||
font-size: var(--cc-text-sm);
|
||||
}
|
||||
|
||||
.cc-alert--error {
|
||||
background: var(--cc-crimson-100);
|
||||
border-color: var(--cc-border-error);
|
||||
color: var(--cc-crimson-600);
|
||||
}
|
||||
|
||||
.cc-alert--success {
|
||||
background: var(--cc-signal-green-100);
|
||||
border-color: var(--cc-border-success);
|
||||
color: var(--cc-signal-green-600);
|
||||
}
|
||||
|
||||
[data-color-mode="dark"] .cc-alert--error {
|
||||
background: rgba(168,40,40,0.12);
|
||||
color: var(--cc-crimson-400);
|
||||
}
|
||||
|
||||
[data-color-mode="dark"] .cc-alert--success {
|
||||
background: rgba(36,107,66,0.12);
|
||||
color: var(--cc-signal-green-400);
|
||||
}
|
||||
|
||||
/* ─── UTILITY CLASSES ────────────────────────────────────────── */
|
||||
|
||||
.cc-font-mono { font-family: var(--cc-font-mono); }
|
||||
.cc-text-xs { font-size: var(--cc-text-xs); }
|
||||
.cc-text-sm { font-size: var(--cc-text-sm); }
|
||||
.cc-text-base { font-size: var(--cc-text-base); }
|
||||
|
||||
.cc-text-primary { color: var(--cc-text-primary); }
|
||||
.cc-text-secondary { color: var(--cc-text-secondary); }
|
||||
.cc-text-tertiary { color: var(--cc-text-tertiary); }
|
||||
.cc-text-accent { color: var(--cc-text-accent); }
|
||||
|
||||
.cc-uppercase { text-transform: uppercase; letter-spacing: var(--cc-tracking-wide); }
|
||||
|
||||
.cc-border-accent { border-left: 2px solid var(--cc-border-accent); }
|
||||
.cc-border-top { border-top: var(--cc-border-width) solid var(--cc-border); }
|
||||
.cc-border-bottom { border-bottom: var(--cc-border-width) solid var(--cc-border); }
|
||||
|
||||
.cc-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ─── GRID LAYOUT HELPERS ────────────────────────────────────── */
|
||||
|
||||
.cc-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--cc-space-2); }
|
||||
.cc-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--cc-space-2); }
|
||||
.cc-stack { display: grid; gap: var(--cc-space-2); }
|
||||
.cc-stack-sm { display: grid; gap: var(--cc-space-1); }
|
||||
|
||||
/* ─── FOCUS VISIBLE ──────────────────────────────────────────── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--cc-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--cc-radius-xs);
|
||||
}
|
||||
@@ -1,2 +1,12 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap");
|
||||
@import url("@tldraw/tldraw/tldraw.css");
|
||||
@import url('./cc-design-system.css');
|
||||
|
||||
.tl-container {
|
||||
--color-panel: var(--cc-bg-panel);
|
||||
--color-text: var(--cc-text-primary);
|
||||
--color-muted: var(--cc-border);
|
||||
--color-muted-1: var(--cc-border-strong);
|
||||
--color-background: var(--cc-bg-canvas);
|
||||
--color-selected: var(--cc-selected);
|
||||
--color-accent: var(--cc-action-primary);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BasePanel } from './shared/BasePanel';
|
||||
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
|
||||
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
||||
|
||||
interface CCPanelProps {
|
||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
||||
isExpanded?: boolean;
|
||||
isPinned?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
@@ -12,7 +10,6 @@ interface CCPanelProps {
|
||||
}
|
||||
|
||||
export const CCPanel: React.FC<CCPanelProps> = ({
|
||||
examMarkerProps,
|
||||
isExpanded,
|
||||
isPinned,
|
||||
onExpandedChange,
|
||||
@@ -32,7 +29,6 @@ export const CCPanel: React.FC<CCPanelProps> = ({
|
||||
|
||||
return (
|
||||
<BasePanel
|
||||
examMarkerProps={examMarkerProps}
|
||||
isExpanded={isExpanded}
|
||||
isPinned={isPinned}
|
||||
onExpandedChange={handleExpandedChange}
|
||||
|
||||
@@ -1,41 +1,14 @@
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import React, { useEffect, useRef, useMemo, useState } from 'react';
|
||||
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,
|
||||
Mic as MicIcon
|
||||
} from '@mui/icons-material';
|
||||
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 { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
||||
import { CCSearchPanel } from './CCSearchPanel'
|
||||
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel'
|
||||
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
|
||||
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';
|
||||
@@ -56,16 +29,12 @@ export const PANEL_TYPES = {
|
||||
{ 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'];
|
||||
export type PanelType = typeof PANEL_TYPES.default[number]['id'];
|
||||
|
||||
interface BasePanelProps {
|
||||
initialPanelType?: PanelType;
|
||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
||||
isExpanded?: boolean;
|
||||
isPinned?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
@@ -78,49 +47,95 @@ interface BasePanelProps {
|
||||
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',
|
||||
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>
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
}
|
||||
}));
|
||||
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',
|
||||
examMarkerProps,
|
||||
isExpanded: controlledIsExpanded,
|
||||
isPinned: controlledIsPinned,
|
||||
onExpandedChange,
|
||||
@@ -128,38 +143,28 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
isMenuOpen = false,
|
||||
onMenuOpenChange = () => {},
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { tldrawPreferences } = useTLDraw();
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Create a dynamic theme based on TLDraw preferences
|
||||
const theme = useMemo(() => {
|
||||
let mode: 'light' | 'dark';
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => setPrefersDarkMode(isDarkMode());
|
||||
mq.addEventListener?.('change', handler);
|
||||
return () => mq.removeEventListener?.('change', handler);
|
||||
}, []);
|
||||
|
||||
if (tldrawPreferences?.colorScheme === 'system') {
|
||||
mode = prefersDarkMode ? 'dark' : 'light';
|
||||
} else {
|
||||
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
const colorMode = (tldrawPreferences?.colorScheme === 'system' ? prefersDarkMode : tldrawPreferences?.colorScheme === 'dark')
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
},
|
||||
});
|
||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||
const availablePanels = PANEL_TYPES.default;
|
||||
|
||||
const isExamMarkerRoute = location.pathname === '/exam-marker';
|
||||
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
|
||||
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
|
||||
|
||||
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
|
||||
isExamMarkerRoute ? 'exam-marker' : initialPanelType
|
||||
);
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
|
||||
const [internalIsPinned, setInternalIsPinned] = React.useState(true);
|
||||
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
||||
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
||||
|
||||
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
||||
const isPinned = controlledIsPinned ?? internalIsPinned;
|
||||
@@ -178,92 +183,25 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
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 'cabinets':
|
||||
return <NavigationIcon />;
|
||||
case 'cc-shapes':
|
||||
return <ShapesIcon />;
|
||||
case 'transcription':
|
||||
return <MicIcon />;
|
||||
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 'cabinets':
|
||||
return 'Manage file cabinets';
|
||||
case 'transcription':
|
||||
return 'Record and transcribe lessons';
|
||||
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 'cabinets':
|
||||
return <CCCabinetsPanel />;
|
||||
@@ -288,16 +226,9 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle menu button click
|
||||
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
onMenuOpenChange(true);
|
||||
};
|
||||
|
||||
// Handle menu close
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
onMenuOpenChange(false);
|
||||
const toggleMenu = (open: boolean) => {
|
||||
setMenuOpen(open);
|
||||
onMenuOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -327,55 +258,60 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="panel-header">
|
||||
<ThemeProvider theme={theme}>
|
||||
<PanelTypeButton
|
||||
onClick={handleMenuClick}
|
||||
endIcon={<ExpandMoreIcon />}
|
||||
startIcon={getIconForPanel(currentPanelType)}
|
||||
>
|
||||
<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}
|
||||
</PanelTypeButton>
|
||||
</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>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
PaperProps={{
|
||||
elevation: 8,
|
||||
sx: {
|
||||
border: '1px solid var(--color-divider)',
|
||||
boxShadow: 'var(--shadow-popup)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{menuOpen && (
|
||||
<div className="cc-menu panel-type-menu" role="menu">
|
||||
{[...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)' }
|
||||
<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);
|
||||
}}
|
||||
secondaryTypographyProps={{
|
||||
sx: { color: 'var(--color-text-secondary)' }
|
||||
}}
|
||||
/>
|
||||
</StyledMenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</ThemeProvider>
|
||||
>
|
||||
<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
|
||||
@@ -383,16 +319,36 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
||||
onClick={handlePinToggle}
|
||||
className="pin-button"
|
||||
>
|
||||
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
|
||||
<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>
|
||||
|
||||
<ThemeProvider theme={theme}>
|
||||
<div className="panel-content">
|
||||
{renderCurrentPanel()}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,495 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -346,9 +346,10 @@ export const CCFilesPanelEnhanced: React.FC = () => {
|
||||
ref={dirInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
onChange={handleFallbackDirectorySelect}
|
||||
// @ts-ignore WebKit directory picker attribute for folder upload
|
||||
webkitdirectory=""
|
||||
/>
|
||||
|
||||
<Tooltip title={isDirectoryPickerSupported() ? "Uses modern directory picker" : "Uses fallback method"}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
||||
import React, { useState, useEffect, useCallback, createContext, useContext, useMemo } from 'react';
|
||||
import {
|
||||
Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
|
||||
ToggleButtonGroup, ToggleButton,
|
||||
@@ -518,7 +518,7 @@ export function CCGraphNavPanel() {
|
||||
const data = await res.json();
|
||||
setTree(data.tree);
|
||||
} catch (err) {
|
||||
logger.error('graph-nav-panel', 'Failed to load graph tree', err);
|
||||
logger.error('navigation-panel', 'Failed to load graph tree', err);
|
||||
setError('Failed to load navigation tree');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -650,12 +650,12 @@ export function CCGraphNavPanel() {
|
||||
}, []);
|
||||
|
||||
const handleCalendarWizardComplete = useCallback(() => {
|
||||
logger.info('graph-nav-panel', 'School calendar setup complete');
|
||||
logger.info('navigation-panel', 'School calendar setup complete');
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
|
||||
logger.info('graph-nav-panel', 'Teacher timetable setup complete', { timetableId });
|
||||
logger.info('navigation-panel', 'Teacher timetable setup complete', { timetableId });
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Box, Typography, styled, Button, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
|
||||
import Save from '@mui/icons-material/Save';
|
||||
import Reset from '@mui/icons-material/RestartAlt';
|
||||
import { RestartAlt } from '@mui/icons-material';
|
||||
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
||||
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
||||
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||
|
||||
@@ -269,3 +269,65 @@
|
||||
color: var(--color-text-2);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* CC Base Shape Styles */
|
||||
.cc-shape-container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px var(--color-muted-1);
|
||||
}
|
||||
|
||||
.cc-shape-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: not-allowed;
|
||||
pointer-events: all;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--shape-header-color, var(--color-muted));
|
||||
padding: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.cc-shape-header .shape-title {
|
||||
color: var(--cc-text-inverse);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cc-shape-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cc-shape-toolbar button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: all;
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.cc-shape-content {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -117,6 +117,20 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: mode === 'development',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||
'vendor-tldraw': ['@tldraw/tldraw', '@tldraw/state', '@tldraw/store'],
|
||||
'vendor-mui': ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
|
||||
'vendor-supabase': ['@supabase/supabase-js'],
|
||||
'vendor-calendar': ['@fullcalendar/core', '@fullcalendar/daygrid', '@fullcalendar/interaction',
|
||||
'@fullcalendar/list', '@fullcalendar/multimonth', '@fullcalendar/react',
|
||||
'@fullcalendar/timegrid'],
|
||||
'vendor-xyflow': ['@xyflow/react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user