Compare commits

..

No commits in common. "143fff64148c53efda7a8356b381c056252f3856" and "bf592886c602d48288da977049dd5a575ff68c7e" have entirely different histories.

40 changed files with 642 additions and 2537 deletions

View File

@ -84,6 +84,7 @@
"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"

View File

@ -1,18 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './src/__tests__/e2e',
testMatch: '**/*.spec.js',
timeout: 30000,
expect: { timeout: 5000 },
fullyParallel: false,
retries: 1,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: undefined,
});

View File

@ -1,5 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'postcss-import': {},
},

View File

@ -1,7 +1,5 @@
import { BrowserRouter } from 'react-router-dom';
import { useMemo, useEffect } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { useTLDraw } from './contexts/TLDrawContext';
import { ThemeProvider } from '@mui/material/styles';
import { theme } from './services/themeService';
import { AuthProvider } from './contexts/AuthContext';
import { TLDrawProvider } from './contexts/TLDrawContext';
@ -10,58 +8,21 @@ import AppRoutes from './AppRoutes';
import { ErrorBoundary } from './components/ErrorBoundary';
import React from 'react';
/**
* 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 (
const App = React.memo(() => (
<ErrorBoundary>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={theme}>
<AuthProvider>
<UserProvider>
<TLDrawProvider>
<DarkModeSync />
<ThemeProvider theme={appTheme}>
<AppRoutes />
</ThemeProvider>
</TLDrawProvider>
</UserProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
</ErrorBoundary>
);
});
));
App.displayName = import.meta.env.VITE_APP_NAME;

View File

@ -45,6 +45,8 @@ 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) {
@ -66,31 +68,6 @@ const FullContextRoutes: React.FC = () => {
return <Outlet />;
};
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
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 <>{children}</>;
};
const AppRoutes: React.FC = () => {
const { user, user_role, loading: isAuthLoading } = useAuth();
const location = useLocation();
@ -159,7 +136,7 @@ const AppRoutes: React.FC = () => {
/>
{/* Authentication only routes - only render if all contexts are initialized */}
<RequireAuth>
{user && (
<Route element={<FullContextRoutes />}>
{/* Timetable Module Routes */}
<Route path="/timetable" element={<TimetablePage />} />
@ -190,7 +167,7 @@ const AppRoutes: React.FC = () => {
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</RequireAuth>
)}
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />

View File

@ -1,47 +0,0 @@
import { test, expect, request } from '@playwright/test';
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
async function serverReachable() {
try {
const ctx = await request.newContext();
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
await ctx.dispose();
return Boolean(res);
} catch {
return false;
}
}
async function login(page) {
await page.goto(`${BASE}/login`);
await page.getByLabel('Email').fill(TEST_EMAIL);
await page.getByLabel('Password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
}
function skipIfUnconfigured() {
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
}
test.describe('Canvas mount', () => {
test.beforeAll(async () => {
skipIfUnconfigured();
const reachable = await serverReachable();
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
});
test('login and show lesson canvas within 5s', async ({ page }) => {
await login(page);
const canvasVisible = await page
.locator('[data-testid="tldraw-canvas"], .tl-container')
.waitFor({ timeout: 10000 })
.then(() => true)
.catch(() => false);
expect(canvasVisible).toBe(true);
await expect(page.locator('[class*="error-state"], [data-testid="error"]').first()).toBeHidden({ timeout: 2000 }).catch(() => {});
});
});

View File

@ -1,39 +0,0 @@
/// <reference types="@playwright/test" />
import { test, expect } from '@playwright/test';
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
test.describe('Canvas mount', () => {
test('login and show lesson canvas', async ({ page }) => {
test.setTimeout(60000);
await page.goto(`${BASE}/login`);
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
async function pingOk() {
try {
const r = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
() => null as any
);
return Boolean(r && (r as any).ok());
} catch {
return false;
}
}
if (!(await pingOk())) test.skip(true, 'Dev server not reachable at ' + BASE);
const canvasVisible = page
.locator('[data-testid="tldraw-canvas"], .tl-container')
.waitFor({ timeout: 8000 })
.then(() => true)
.catch(() => false);
expect((await canvasVisible)).toBe(true);
await expect(page.getByText(/error|failed to load/i).first()).toBeHidden();
});
});

View File

@ -1,54 +0,0 @@
import { test, expect, request } from '@playwright/test';
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
async function serverReachable() {
try {
const ctx = await request.newContext();
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
await ctx.dispose();
return Boolean(res);
} catch {
return false;
}
}
async function login(page) {
await page.goto(`${BASE}/login`);
await page.getByLabel('Email').fill(TEST_EMAIL);
await page.getByLabel('Password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
}
function skipIfUnconfigured() {
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
}
test.describe('Node navigation', () => {
test.beforeAll(async () => {
skipIfUnconfigured();
const reachable = await serverReachable();
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
});
test('canvas remounts cleanly on node navigation', async ({ page }) => {
const errors = [];
page.on('pageerror', err => errors.push(err.message));
await login(page);
await page.locator('[data-testid="tldraw-canvas"], .tl-container').waitFor({ timeout: 10000 });
const navLinks = page.locator('nav a, [data-testid="nav-node"]');
const count = await navLinks.count();
if (count > 1) {
await navLinks.nth(1).click();
await page.waitForTimeout(2000);
const hasCanvas = await page.locator('[data-testid="tldraw-canvas"], .tl-container')
.waitFor({ timeout: 8000 }).then(() => true).catch(() => false);
expect(hasCanvas).toBe(true);
}
const criticalErrors = errors.filter(e => /uncaught|undefined is not/i.test(e));
expect(criticalErrors).toHaveLength(0);
});
});

View File

@ -1,47 +0,0 @@
/// <reference types="@playwright/test" />
import { test, expect } from '@playwright/test';
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
test.describe('Node navigation', () => {
test('canvas remounts on node change', async ({ page }) => {
test.setTimeout(60000);
async function serverReachable() {
try {
const res = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
() => null as any
);
return Boolean(res && (res as any).ok());
} catch {
return false;
}
}
if (!(await serverReachable())) test.skip(true, 'Dev server not reachable at ' + BASE);
const consoleMessages: string[] = [];
page.on('console', (msg: any) => consoleMessages.push((msg.text() as string) || ''));
page.on('pageerror', (err: any) => consoleMessages.push('pageerror: ' + ((err.message as string) || 'unknown')));
await page.goto(`${BASE}/login`);
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
const canvas = page.locator('[data-testid="tldraw-canvas"], .tl-container');
if ((await canvas.count()) === 0) await canvas.first().waitFor({ timeout: 8000 });
await canvas.first().waitFor({ state: 'visible', timeout: 8000 });
const nodes = page.locator('a[href*="/node/"]').first();
if ((await nodes.count()) === 0) test.skip(true, 'No node navigation links found');
await nodes.click();
await page.waitForURL((url: URL) => /\/node\/\d+/.test(url.pathname), { timeout: 12000 });
await expect(canvas.first()).toBeVisible();
const errorTexts = consoleMessages.some((m) => /error|failed/i.test(m));
expect(errorTexts).toBe(false);
});
});

View File

@ -1,54 +0,0 @@
import { test, expect, request } from '@playwright/test';
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
const TEST_EMAIL = process.env.VITE_TEST_TEACHER_EMAIL;
const TEST_PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD;
async function serverReachable() {
try {
const ctx = await request.newContext();
const res = await ctx.get(BASE, { timeout: 3000 }).catch(() => null);
await ctx.dispose();
return Boolean(res);
} catch {
return false;
}
}
async function login(page) {
await page.goto(`${BASE}/login`);
await page.getByLabel('Email').fill(TEST_EMAIL);
await page.getByLabel('Password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL((url) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 15000 });
}
function skipIfUnconfigured() {
if (!TEST_EMAIL || !TEST_PASSWORD) test.skip(true, 'VITE_TEST_TEACHER_EMAIL / VITE_TEST_TEACHER_PASSWORD not set');
}
test.describe('Transcription toggle', () => {
test.beforeAll(async () => {
skipIfUnconfigured();
const reachable = await serverReachable();
test.skip(!reachable, `Dev server not reachable at ${BASE}`);
});
test('record button toggles isRecording state', async ({ page, context }) => {
await context.grantPermissions(['microphone']);
await login(page);
await page.locator('[data-testid="tldraw-canvas"], .tl-container').waitFor({ timeout: 10000 });
const recordBtn = page.locator('[data-testid="record-button"], button[aria-label*="ecord" i]').first();
const btnVisible = await recordBtn.waitFor({ timeout: 5000 }).then(() => true).catch(() => false);
if (btnVisible) {
const labelBefore = await recordBtn.getAttribute('aria-label').catch(() => '');
await recordBtn.click();
await page.waitForTimeout(1000);
const labelAfter = await recordBtn.getAttribute('aria-label').catch(() => '');
expect(labelBefore).not.toEqual(labelAfter);
await recordBtn.click();
} else {
test.skip(true, 'Record button not visible on current node type');
}
});
});

View File

@ -1,41 +0,0 @@
/// <reference types="@playwright/test" />
import { test, expect } from '@playwright/test';
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
test.describe('Transcription toggle', () => {
test('record and stop preserve segments', async ({ page }) => {
test.setTimeout(60000);
async function serverReachable() {
try {
const res = await page.request.get(new URL('/api/health', BASE).toString(), { timeout: 4000 }).catch(
() => null as any
);
return Boolean(res && (res as any).ok());
} catch {
return false;
}
}
if (!(await serverReachable())) test.skip(true, 'Dev server not reachable at ' + BASE);
await page.goto(`${BASE}/login`);
const email = process.env.VITE_TEST_TEACHER_EMAIL || 'teacher@test';
const password = process.env.VITE_TEST_TEACHER_PASSWORD || 'password';
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await page.waitForURL((url: URL) => /\/dashboard|\/node\//.test(url.pathname), { timeout: 12000 });
const recordButton = page.locator('[data-testid="record-button"], button[aria-label*="record" i], button[aria-label*="Record" i]').first();
if ((await recordButton.count()) === 0) test.skip(true, 'No transcription record control found');
const beforeCount = await page.locator('[data-testid="segment"], .segment, .transcription-segment').count();
await recordButton.click();
await expect(recordButton).toHaveAttribute('aria-pressed', 'true');
await recordButton.click();
const afterCount = await page.locator('[data-testid="segment"], .segment, .transcription-segment').count();
expect(afterCount).toBeGreaterThanOrEqual(beforeCount);
});
});

View File

@ -18,7 +18,6 @@ export interface AuthContextType {
signOut: () => Promise<void>;
clearError: () => void;
bootstrapData: BootstrapResponse | null;
isAuthResolving: boolean;
}
export const AuthContext = createContext<AuthContextType>({
@ -30,8 +29,7 @@ export const AuthContext = createContext<AuthContextType>({
signIn: async () => {},
signOut: async () => {},
clearError: () => {},
bootstrapData: null,
isAuthResolving: false
bootstrapData: null
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
@ -42,7 +40,6 @@ 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) => {
@ -95,7 +92,6 @@ 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);
@ -116,13 +112,11 @@ 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);
@ -143,13 +137,11 @@ 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);
@ -222,8 +214,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
signIn,
signOut,
clearError,
bootstrapData,
isAuthResolving
bootstrapData
}}
>
{children}

View File

@ -23,7 +23,6 @@ export type LogCategory =
| 'neo-institute-context'
| 'auth-service'
| 'graph-service'
| 'graph-sidebar'
| 'registration-service'
| 'provisioning-service'
| 'snapshot-service'
@ -112,7 +111,6 @@ export type LogCategory =
| 'user-context'
| 'neo-user-context'
| 'neo-institute-context'
| 'cc-live-transcription'
| 'search-service';
interface LogConfig {

View File

@ -1,6 +1,7 @@
/* src/index.css */
/* Tailwind removed — utility classes provided by tailwind-compat.css shim */
@import './tailwind-compat.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base styles */
html,
@ -151,10 +152,10 @@ body {
.login-role-header {
font-weight: 600 !important;
color: var(--cc-action-primary) !important;
color: #1976d2 !important;
margin-bottom: 24px !important;
padding-bottom: 8px !important;
border-bottom: 2px solid var(--cc-action-primary) !important;
border-bottom: 2px solid #1976d2 !important;
width: fit-content !important;
}

View File

@ -6,50 +6,34 @@ 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, isAuthResolving } = useAuth();
const { user, signIn } = useAuth();
const [error, setError] = useState<string | null>(null);
logger.debug('login-page', '🔍 Login page loaded', {
hasUser: !!user,
isAuthResolving
hasUser: !!user
});
useEffect(() => {
if (!isAuthResolving && user) {
if (user) {
navigate('/dashboard');
}
}, [user, isAuthResolving, navigate]);
}, [user, 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);
const message = getLoginErrorMessage(error);
setError(message);
setError(error instanceof Error ? error.message : 'Login failed');
throw error;
}
};
if (user && !isAuthResolving) {
if (user) {
return null;
}

View File

@ -13,7 +13,7 @@ import { EmailSignupForm } from './EmailSignupForm';
import { EmailCredentials } from '../../services/auth/authService';
import { RegistrationService } from '../../services/auth/registrationService';
import { logger } from '../../debugConfig';
import MicrosoftIcon from '@mui/icons-material/Microsoft.js';
import MicrosoftIcon from '@mui/icons-material/Microsoft';
const SignupPage: React.FC = () => {
const location = useLocation();

View File

@ -31,7 +31,6 @@ import {
} from '@mui/material';
import {
CloudUpload as UploadIcon,
Upload,
Folder as FolderIcon,
FolderOpen as FolderOpenIcon,
Description as FileIcon,
@ -40,12 +39,8 @@ import {
PlayArrow as ProcessIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon,
InsertDriveFile,
Info as InfoIcon
} from '@mui/icons-material';
const FolderOpen = FolderOpenIcon;
const Refresh = RefreshIcon;
const Info = InfoIcon;
import { supabase } from '../../supabaseClient';
import {
pickDirectory,

View File

@ -73,9 +73,8 @@ export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
tickProgress();
}
const pdfBytes = await pdf.save();
const url = URL.createObjectURL(
new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })
new Blob([await pdf.save()], { type: 'application/pdf' })
);
tickProgress();

View File

@ -6,7 +6,8 @@ import {
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore
TLStore,
TLStoreWithStatus
} from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useAuth } from '../../contexts/AuthContext';
@ -14,6 +15,7 @@ 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';
@ -32,7 +34,9 @@ import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
import { CircularProgress, Alert, Snackbar } from '@mui/material';
import { ErrorBoundary } from '../../components/ErrorBoundary';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
import { NodeData } from '../../types/graph-shape';
import { NavigationNode } from '../../types/navigation';
interface LoadingState {
status: 'ready' | 'loading' | 'error';
@ -63,8 +67,9 @@ export default function SinglePlayerPage() {
status: 'ready',
error: ''
});
const storeRef = useRef<TLStore | null>(null);
const [storeReady, setStoreReady] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isEditorReady, setIsEditorReady] = useState(false);
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
// TLDraw user preferences
const tldrawUser = useTldrawUser({
@ -80,83 +85,248 @@ export default function SinglePlayerPage() {
setUserPreferences: setTldrawPreferences
});
// Orchestrated context.node lifecycle — replaces 3 separate effects
const [canvasPhase, setCanvasPhase] = useState<
'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error'
>('idle');
const phaseError = useRef<string>('');
// Initialize store — runs as soon as user is ready, no editor needed for store creation
useEffect(() => {
if (!user) return;
let cancelled = false;
if (!user) {
logger.debug('single-player-page', '⏳ Waiting for user data');
return;
}
const run = async () => {
logger.info('single-player-page', '🔄 Starting store initialization', {
isEditorReady,
hasUser: !!user,
userType: user.user_type,
username: user.username
});
const initializeStoreAndSnapshot = async () => {
try {
setCanvasPhase('store-init');
setLoadingState({ status: 'loading', error: '' });
// 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');
const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined);
if (accessToken) snapSvc.setAccessToken(accessToken);
snapshotServiceRef.current = snapSvc;
// 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) {
setCanvasPhase('snapshot-loading');
const path = context.node.node_storage_path;
if (path) {
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(
path,
nodeStoragePath,
accessToken || '',
newStore,
setLoadingState,
undefined,
editorRef.current || undefined
);
snapSvc.setCurrentNodePath(path);
// 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');
}
if (cancelled) {
newStore.dispose();
// 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;
}
let debounce: ReturnType<typeof setTimeout> | null = null;
newStore.listen(() => {
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(async () => {
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
}, 2000);
// 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
}
});
storeRef.current = newStore;
setStoreReady(true);
setCanvasPhase('ready');
} catch (e) {
phaseError.current = e instanceof Error ? e.message : 'Init failed';
setCanvasPhase('error');
// 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;
}
};
run();
return () => {
cancelled = true;
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
snapshotServiceRef.current?.clearCurrentNode();
snapshotServiceRef.current = null;
storeRef.current?.dispose();
storeRef.current = null;
setStoreReady(false);
setCanvasPhase('idle');
};
}, [user?.id, context.node?.id]);
initializeStoreAndSnapshot();
}, [user, context.node]);
// Handle navigation changes — save previous snapshot, load next one.
// No automatic node shape placement: the canvas shows only persisted state.
// Handle initial node placement
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;
}
// 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
});
// 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'
});
return;
}
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 });
}
}
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'
});
}
};
placeInitialNode();
}, [context.node, store, isInitialLoad]);
// 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]);
// Inject auth and trigger initial context when token is ready
useEffect(() => {
@ -315,7 +485,7 @@ export default function SinglePlayerPage() {
top: `${HEADER_HEIGHT}px`,
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !storeReady) && (
{(loadingState.status === 'loading' || !store) && (
<div style={{
position: 'absolute',
top: 0,
@ -343,15 +513,9 @@ export default function SinglePlayerPage() {
</Alert>
</Snackbar>
<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
{store && <Tldraw
user={tldrawUser}
store={storeRef.current}
store={store}
tools={singlePlayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
@ -384,17 +548,57 @@ 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,
};
};

View File

@ -19,17 +19,12 @@ 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');
}
@ -57,16 +52,18 @@ export class SharedStoreService {
}
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
if (!this.isDirty) {
return;
}
this.isDirty = false;
const now = Date.now();
if (now - this.lastSaveTime >= this.config.saveInterval) {
await this.saveSnapshot(getSnapshot(this.store), setLoadingState);
const currentSnapshot = getSnapshot(this.store);
const savedSnapshot = storageService.get(StorageKeys.LOCAL_SNAPSHOT);
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');
}
}
}
@ -112,10 +109,7 @@ 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');
}
}

View File

@ -117,9 +117,9 @@ export class NavigationSnapshotService {
try {
if (editor) {
loadSnapshot(editor.store, snapshotCopy as any);
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
} else {
loadSnapshot(store, snapshotCopy as any);
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
}
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
} catch (err) {

View File

@ -1,16 +1,3 @@
/* 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))
}

View File

@ -1,233 +0,0 @@
/*
* 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; }

View File

@ -1,6 +1,7 @@
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 {
@ -39,32 +40,44 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
component(shape: T) {
const {
props: { w, h, isLocked, headerColor },
props: { w, h, isLocked },
} = shape
const toolbarItems = this.getToolbarItems(shape)
return (
<HTMLContainer
id={shape.id}
className="cc-shape-container"
style={{
width: toDomPrecision(w),
height: toDomPrecision(h),
'--shape-header-color': headerColor,
} as React.CSSProperties}
backgroundColor: shape.props.headerColor,
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
overflow: 'hidden',
position: 'relative',
}}
>
{/* Header */}
<div
className="cc-shape-header"
style={{ cursor: isLocked ? 'not-allowed' : 'move' }}
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,
}}
>
<span className="shape-title">{shape.props.title}</span>
<div className="cc-shape-toolbar">
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
{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()
@ -76,19 +89,46 @@ 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: 'var(--cc-text-inverse)' }}>🔒</span>}
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
</div>
</div>
{/* Content */}
<div
className="cc-shape-content"
style={{ backgroundColor: shape.props.backgroundColor }}
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,
}}
>
{this.renderContent(shape)}
</div>

View File

@ -17,16 +17,10 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
const defaultProps = getDefaultCCTimetableLessonNodeProps()
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
return {
...defaultProps,
headerColor: '',
school_db_name: '',
school_period_id: '',
subject_class: '',
date: '',
start_time: '',
end_time: '',
period_code: '',
headerColor: theme.headerColor,
}
}

View File

@ -543,8 +543,6 @@ export const getDefaultCCTimetableLessonNodeProps = () => ({
start_time: '',
end_time: '',
period_code: '',
school_db_name: '',
school_period_id: '',
})
export const getDefaultCCPlannedLessonNodeProps = () => ({

View File

@ -22,6 +22,7 @@ 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'
@ -30,11 +31,12 @@ 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 './CCUserTimetableLessonNodeShapeUtil'
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
// Create a const object with all node types
export const NODE_SHAPE_TYPES = {
USER: CCUserNodeShapeUtil.type,
@ -116,7 +118,8 @@ export type AllNodeShapes =
| CCTimetableLessonNodeShape
| CCPlannedLessonNodeShape
| CCDepartmentStructureNodeShape
| CCUserTeacherTimetableNodeShape;
| CCUserTeacherTimetableNodeShape
| CCTimetableLessonNodeShape;
// Export all shape utils in an object for easy access
export const ShapeUtils = {
@ -156,6 +159,7 @@ 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

View File

@ -1,441 +0,0 @@
/**
* 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')
})
})
})

View File

@ -6,7 +6,6 @@ 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
@ -84,7 +83,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
height: '48px',
borderRadius: '50%',
border: 'none',
backgroundColor: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)',
backgroundColor: isRecording ? '#f44336' : '#4CAF50',
color: 'white',
fontSize: '24px',
cursor: 'pointer',
@ -106,7 +105,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)'
color: isRecording ? '#f44336' : '#4CAF50'
}}>
{isRecording ? 'Recording...' : 'Ready'}
</div>
@ -208,11 +207,11 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
}
private toggleRecording(shape: CCLiveTranscriptionShape) {
logger.debug('cc-live-transcription', '🎤 Toggle recording clicked');
console.log('🎤 Toggle recording clicked');
const { id } = shape;
const { isRecording } = shape.props;
logger.debug('cc-live-transcription', 'Current state', { id, isRecording });
console.log('Current state:', { id, isRecording });
// When starting new recording, preserve existing props but reset segments
const newProps = !isRecording ? {
@ -233,13 +232,13 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
});
const manager = TranscriptionManager.getManager(this.editor);
logger.debug('cc-live-transcription', 'Got transcription manager');
console.log('Got transcription manager');
if (!isRecording) {
logger.debug('cc-live-transcription', 'Starting transcription...');
console.log('Starting transcription...');
manager.startTranscription(id);
} else {
logger.debug('cc-live-transcription', 'Stopping transcription...');
console.log('Stopping transcription...');
manager.stopTranscription();
}
}
@ -250,10 +249,10 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
isConfirmed: boolean,
metadata?: { start: string | number, end: string | number }
) {
logger.debug('cc-live-transcription', '📝 Updating text', { id, text, isConfirmed, metadata });
console.log('📝 Updating text:', { id, text, isConfirmed, metadata });
const shape = this.editor.getShape<CCLiveTranscriptionShape>(id);
if (!shape) {
logger.warn('cc-live-transcription', '❌ Shape not found for updating text', id);
console.warn('❌ Shape not found for updating text:', id);
return;
}
@ -309,7 +308,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
});
}
logger.debug('cc-live-transcription', '✅ Text updated');
console.log('✅ Text updated');
}
}

View File

@ -27,8 +27,8 @@ export class TranscriptionManager {
this.transcriptionService = new TranscriptionService();
// Set up callback for transcription updates
this.transcriptionService.setServerSegmentsCallback((segments: Array<{ text: string; start: number; end: number }>, isLastLive: boolean) => {
console.log('📝 Transcription update received:', { segments: segments.length, isLastLive });
this.transcriptionService.setTranscriptionCallback((text: string, isFinal: boolean, metadata: { start: number, end: number }) => {
console.log('📝 Transcription update received:', { text, metadata });
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
if (!util) {
console.warn('❌ Shape util not found');
@ -36,10 +36,6 @@ 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) {

View File

@ -18,40 +18,40 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
overflowY: 'auto',
maxHeight: '100%',
minWidth: '200px',
backgroundColor: 'var(--cc-navy-50)',
borderLeft: '1px solid var(--cc-border)',
backgroundColor: '#f5f5f5',
borderLeft: '1px solid #ddd',
},
title: {
margin: '0 0 10px 0',
fontSize: '16px',
fontWeight: 'bold',
color: 'var(--cc-text-primary)',
color: '#333',
},
line: {
padding: '5px',
marginBottom: '5px',
borderRadius: '4px',
fontSize: '14px',
color: 'var(--cc-text-primary)',
backgroundColor: 'var(--cc-bg-canvas)',
border: '1px solid var(--cc-border-strong)',
color: '#333',
backgroundColor: '#fff',
border: '1px solid #eee',
},
activeLine: {
backgroundColor: 'var(--cc-navy-50)',
border: '1px solid var(--cc-action-primary)',
color: 'var(--cc-action-primary)',
backgroundColor: '#e3f2fd',
border: '1px solid #2196f3',
color: '#1565c0',
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: 'var(--cc-text-tertiary)',
color: '#666',
marginRight: '8px',
fontWeight: 'bold',
},
loading: {
color: 'var(--cc-text-muted)',
color: '#666',
fontStyle: 'italic',
},
},
@ -64,14 +64,14 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
},
button: {
padding: '8px 12px',
backgroundColor: 'var(--cc-navy-50)',
border: '1px solid var(--cc-border)',
backgroundColor: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
color: 'var(--cc-text-primary)',
color: '#333',
fontSize: '14px',
'&:hover': {
backgroundColor: 'var(--cc-border-strong)',
backgroundColor: '#e0e0e0',
},
},
},

View File

@ -1,976 +0,0 @@
/* ============================================================
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);
}

View File

@ -1,12 +1,2 @@
@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);
}

View File

@ -1,6 +1,31 @@
import React, { useEffect, useRef, useMemo, useState } from 'react';
import React, { useEffect, useRef, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { TldrawUiButton } from '@tldraw/tldraw';
import {
Button,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
styled,
ThemeProvider,
createTheme,
useMediaQuery
} from '@mui/material';
import {
PushPin as PushPinIcon,
PushPinOutlined as PushPinOutlinedIcon,
ExpandMore as ExpandMoreIcon,
Category as ShapesIcon,
Slideshow as SlidesIcon,
YouTube as YouTubeIcon,
AccountTree as GraphIcon,
Search as SearchIcon,
Navigation as NavigationIcon,
Save as NodeIcon,
Assignment as ExamIcon,
Mic as MicIcon
} from '@mui/icons-material';
import { CCShapesPanel } from './CCShapesPanel';
import { CCSlidesPanel } from './CCSlidesPanel';
import { CCFilesPanel } from './CCFilesPanel';
@ -8,9 +33,9 @@ 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';
@ -53,102 +78,45 @@ interface BasePanelProps {
onMenuOpenChange?: (open: boolean) => void;
}
function getIconSvg(panelId: PanelType, size = '1.25rem') {
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
switch (panelId) {
case 'cabinets':
return (
<svg {...common}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
case 'transcription':
return (
<svg {...common}>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="6" y2="21" />
<line x1="16" y1="23" x2="14" y2="21" />
</svg>
);
case 'cc-shapes':
return (
<svg {...common}>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
);
case 'slides':
return (
<svg {...common}>
<rect x="2" y="3" width="13" height="18" rx="2" />
<path d="M15 8h4l3 3v7h-7V8z" />
</svg>
);
case 'youtube':
return (
<svg {...common}>
<rect x="2" y="4" width="20" height="16" rx="4" />
<polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none" />
</svg>
);
case 'graph':
return (
<svg {...common}>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
);
case 'search':
return (
<svg {...common}>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
case 'navigation':
return (
<svg {...common}>
<polygon points="3 11 22 2 13 21 11 13 3 11" />
</svg>
);
case 'node-snapshot':
return (
<svg {...common}>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
);
case 'exam-marker':
return (
<svg {...common}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
);
default:
return (
<svg {...common}>
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg>
);
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 isDarkMode() {
if (typeof window === 'undefined') return false;
return document.documentElement.dataset.colorMode === 'dark' ||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const StyledMenuItem = styled(MenuItem)(() => ({
gap: '8px',
padding: '8px 16px',
transition: 'background-color 200ms ease',
'&:hover': {
backgroundColor: 'var(--color-hover)',
'& .MuiListItemIcon-root': {
color: 'var(--color-selected)',
}
},
'& .MuiListItemIcon-root': {
color: 'var(--color-text)',
minWidth: '32px',
transition: 'color 200ms ease',
'& .MuiSvgIcon-root': {
fontSize: '1.25rem',
}
}
}));
export const BasePanel: React.FC<BasePanelProps> = ({
initialPanelType = 'files',
@ -162,30 +130,36 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}) => {
const location = useLocation();
const { tldrawPreferences } = useTLDraw();
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setPrefersDarkMode(isDarkMode());
mq.addEventListener?.('change', handler);
return () => mq.removeEventListener?.('change', handler);
}, []);
// Create a dynamic theme based on TLDraw preferences
const theme = useMemo(() => {
let mode: 'light' | 'dark';
const colorMode = (tldrawPreferences?.colorScheme === 'system' ? prefersDarkMode : tldrawPreferences?.colorScheme === 'dark')
? 'dark'
: 'light';
if (tldrawPreferences?.colorScheme === 'system') {
mode = prefersDarkMode ? 'dark' : 'light';
} else {
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
}
return createTheme({
palette: {
mode,
},
});
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
const isExamMarkerRoute = location.pathname === '/exam-marker';
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
isExamMarkerRoute ? 'exam-marker' : initialPanelType
);
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
const [internalIsPinned, setInternalIsPinned] = useState(true);
// Use controlled state if provided, otherwise use internal state
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
const [internalIsPinned, setInternalIsPinned] = React.useState(true);
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
const isPinned = controlledIsPinned ?? internalIsPinned;
@ -204,24 +178,87 @@ 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;
@ -251,9 +288,16 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}
};
const toggleMenu = (open: boolean) => {
setMenuOpen(open);
onMenuOpenChange(open);
// Handle menu button click
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
onMenuOpenChange(true);
};
// Handle menu close
const handleMenuClose = () => {
setMenuAnchorEl(null);
onMenuOpenChange(false);
};
return (
@ -283,61 +327,55 @@ export const BasePanel: React.FC<BasePanelProps> = ({
}}
>
<div className="panel-header">
<button
type="button"
className="cc-btn cc-btn--secondary cc-panel-selector"
onClick={() => toggleMenu(!menuOpen)}
<ThemeProvider theme={theme}>
<PanelTypeButton
onClick={handleMenuClick}
endIcon={<ExpandMoreIcon />}
startIcon={getIconForPanel(currentPanelType)}
>
<span className="cc-btn-start-icon">
{getIconSvg(currentPanelType)}
</span>
<span className="cc-btn-label">
{availablePanels.find(p => p.id === currentPanelType)?.label}
</span>
<span className="cc-btn-end-icon">
<svg width="1.25rem" height="1.25rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
</PanelTypeButton>
{menuOpen && (
<div className="cc-menu panel-type-menu" role="menu">
<Menu
anchorEl={menuAnchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
PaperProps={{
elevation: 8,
sx: {
border: '1px solid var(--color-divider)',
boxShadow: 'var(--shadow-popup)',
}
}}
>
{[...availablePanels]
.sort((a, b) => a.order - b.order)
.map(type => (
<button
<StyledMenuItem
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);
handleMenuClose();
}}
selected={currentPanelType === type.id}
>
<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'}
{type.id === 'exam-marker' && 'Mark and grade exams'}
</span>
</span>
</button>
<ListItemIcon>
{getIconForPanel(type.id as PanelType)}
</ListItemIcon>
<ListItemText
primary={type.label}
secondary={getDescriptionForPanel(type.id as PanelType)}
primaryTypographyProps={{
sx: { color: 'var(--color-text)' }
}}
secondaryTypographyProps={{
sx: { color: 'var(--color-text-secondary)' }
}}
/>
</StyledMenuItem>
))}
</div>
)}
</Menu>
</ThemeProvider>
<div className="panel-header-actions">
<TldrawUiButton
@ -345,36 +383,16 @@ export const BasePanel: React.FC<BasePanelProps> = ({
onClick={handlePinToggle}
className="pin-button"
>
<span className="cc-icon-button">
<svg
width="1.25rem"
height="1.25rem"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{isPinned ? (
<>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z" />
</>
) : (
<>
<circle cx="12" cy="12" r="10" />
<path d="M7.5 7.5l9 9" />
</>
)}
</svg>
</span>
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
</TldrawUiButton>
</div>
</div>
<ThemeProvider theme={theme}>
<div className="panel-content">
{renderCurrentPanel()}
</div>
</ThemeProvider>
</div>
)}
</>

View File

@ -200,7 +200,7 @@ export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
const pdfBytes = await newPdf.save();
const url = URL.createObjectURL(
new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' })
new Blob([pdfBytes], { type: 'application/pdf' })
);
tickProgress();
@ -297,7 +297,7 @@ export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
// Save the combined PDF
const pdfBytes = await newPdf.save();
const url = URL.createObjectURL(new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' }));
const url = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
const a = document.createElement('a');
a.href = url;
a.download = currentPdf.name;

View File

@ -346,10 +346,9 @@ 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"}>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, createContext, useContext, useMemo } from 'react';
import React, { useState, useEffect, useCallback, createContext, useContext } 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('navigation-panel', 'Failed to load graph tree', err);
logger.error('graph-nav-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('navigation-panel', 'School calendar setup complete');
logger.info('graph-nav-panel', 'School calendar setup complete');
refreshAll();
}, [refreshAll]);
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
logger.info('navigation-panel', 'Teacher timetable setup complete', { timetableId });
logger.info('graph-nav-panel', 'Teacher timetable setup complete', { timetableId });
refreshAll();
}, [refreshAll]);

View File

@ -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 { RestartAlt } from '@mui/icons-material';
import Reset from '@mui/icons-material/RestartAlt';
import { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
import { useNavigationStore } from '../../../../../../stores/navigationStore';
import { useAuth } from '../../../../../../contexts/AuthContext';

View File

@ -269,65 +269,3 @@
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;
}

View File

@ -117,20 +117,6 @@ 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: {