Compare commits
No commits in common. "143fff64148c53efda7a8356b381c056252f3856" and "bf592886c602d48288da977049dd5a575ff68c7e" have entirely different histories.
143fff6414
...
bf592886c6
@ -84,6 +84,7 @@
|
|||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"react-refresh": "^0.14.2",
|
"react-refresh": "^0.14.2",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
|
|||||||
@ -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,
|
|
||||||
});
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
'postcss-import': {},
|
'postcss-import': {},
|
||||||
},
|
},
|
||||||
|
|||||||
49
src/App.tsx
49
src/App.tsx
@ -1,7 +1,5 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { useMemo, useEffect } from 'react';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
||||||
import { useTLDraw } from './contexts/TLDrawContext';
|
|
||||||
import { theme } from './services/themeService';
|
import { theme } from './services/themeService';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { TLDrawProvider } from './contexts/TLDrawContext';
|
import { TLDrawProvider } from './contexts/TLDrawContext';
|
||||||
@ -10,58 +8,21 @@ import AppRoutes from './AppRoutes';
|
|||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
/**
|
const App = React.memo(() => (
|
||||||
* Syncs tldraw's color scheme to data-color-mode on <html>.
|
|
||||||
* Must live inside TLDrawProvider so useTLDraw() reads live preferences.
|
|
||||||
* This activates the [data-color-mode="dark"] overrides in cc-design-system.css.
|
|
||||||
* tldraw itself uses tl-theme__dark class on .tl-container — we mirror that
|
|
||||||
* to the document root so --cc-* token overrides apply globally.
|
|
||||||
*/
|
|
||||||
const DarkModeSync: React.FC = () => {
|
|
||||||
const { tldrawPreferences } = useTLDraw();
|
|
||||||
const prefersDarkMode =
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const scheme = tldrawPreferences?.colorScheme ?? 'system';
|
|
||||||
const isDark = scheme === 'dark' || (scheme === 'system' && prefersDarkMode);
|
|
||||||
document.documentElement.setAttribute('data-color-mode', isDark ? 'dark' : 'light');
|
|
||||||
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = React.memo(() => {
|
|
||||||
const prefersDarkMode =
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
|
|
||||||
const appTheme = useMemo(() => {
|
|
||||||
// App is outside TLDrawProvider so tldrawPreferences is always null here.
|
|
||||||
// Theme defaults to OS preference; DarkModeSync (inside TLDrawProvider) handles
|
|
||||||
// updating data-color-mode once tldraw preferences are loaded.
|
|
||||||
const mode = prefersDarkMode ? 'dark' : 'light';
|
|
||||||
return createTheme({ palette: { mode } });
|
|
||||||
}, [prefersDarkMode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<TLDrawProvider>
|
<TLDrawProvider>
|
||||||
<DarkModeSync />
|
|
||||||
<ThemeProvider theme={appTheme}>
|
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ThemeProvider>
|
|
||||||
</TLDrawProvider>
|
</TLDrawProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
));
|
||||||
});
|
|
||||||
|
|
||||||
App.displayName = import.meta.env.VITE_APP_NAME;
|
App.displayName = import.meta.env.VITE_APP_NAME;
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,8 @@ import {
|
|||||||
} from './pages/timetable';
|
} from './pages/timetable';
|
||||||
|
|
||||||
const FullContextRoutes: React.FC = () => {
|
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();
|
const { isInitialized: isUserInitialized } = useUser();
|
||||||
|
|
||||||
if (!isUserInitialized) {
|
if (!isUserInitialized) {
|
||||||
@ -66,31 +68,6 @@ const FullContextRoutes: React.FC = () => {
|
|||||||
return <Outlet />;
|
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 AppRoutes: React.FC = () => {
|
||||||
const { user, user_role, loading: isAuthLoading } = useAuth();
|
const { user, user_role, loading: isAuthLoading } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -159,7 +136,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||||
<RequireAuth>
|
{user && (
|
||||||
<Route element={<FullContextRoutes />}>
|
<Route element={<FullContextRoutes />}>
|
||||||
{/* Timetable Module Routes */}
|
{/* Timetable Module Routes */}
|
||||||
<Route path="/timetable" element={<TimetablePage />} />
|
<Route path="/timetable" element={<TimetablePage />} />
|
||||||
@ -190,7 +167,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</RequireAuth>
|
)}
|
||||||
|
|
||||||
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
||||||
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
||||||
|
|||||||
@ -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(() => {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -18,7 +18,6 @@ export interface AuthContextType {
|
|||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
bootstrapData: BootstrapResponse | null;
|
bootstrapData: BootstrapResponse | null;
|
||||||
isAuthResolving: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType>({
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
@ -30,8 +29,7 @@ export const AuthContext = createContext<AuthContextType>({
|
|||||||
signIn: async () => {},
|
signIn: async () => {},
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
clearError: () => {},
|
clearError: () => {},
|
||||||
bootstrapData: null,
|
bootstrapData: null
|
||||||
isAuthResolving: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
@ -42,7 +40,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
||||||
const [isAuthResolving, setIsAuthResolving] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const persistSession = useCallback((session: Session | null) => {
|
const persistSession = useCallback((session: Session | null) => {
|
||||||
@ -95,7 +92,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (event === 'SIGNED_IN') {
|
if (event === 'SIGNED_IN') {
|
||||||
persistSession(session ?? null);
|
persistSession(session ?? null);
|
||||||
setIsAuthResolving(!!session?.user);
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
try {
|
try {
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
@ -116,13 +112,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsAuthResolving(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
if (event === 'INITIAL_SESSION' || event === 'TOKEN_REFRESHED') {
|
||||||
persistSession(session ?? null);
|
persistSession(session ?? null);
|
||||||
setIsAuthResolving(!!session?.user);
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
try {
|
try {
|
||||||
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
const { user: resolvedUser, role } = await buildUserFromSupabase(session.user);
|
||||||
@ -143,13 +137,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsAuthResolving(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'SIGNED_OUT') {
|
if (event === 'SIGNED_OUT') {
|
||||||
persistSession(null);
|
persistSession(null);
|
||||||
setIsAuthResolving(false);
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
@ -222,8 +214,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
clearError,
|
clearError,
|
||||||
bootstrapData,
|
bootstrapData
|
||||||
isAuthResolving
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -23,7 +23,6 @@ export type LogCategory =
|
|||||||
| 'neo-institute-context'
|
| 'neo-institute-context'
|
||||||
| 'auth-service'
|
| 'auth-service'
|
||||||
| 'graph-service'
|
| 'graph-service'
|
||||||
| 'graph-sidebar'
|
|
||||||
| 'registration-service'
|
| 'registration-service'
|
||||||
| 'provisioning-service'
|
| 'provisioning-service'
|
||||||
| 'snapshot-service'
|
| 'snapshot-service'
|
||||||
@ -112,7 +111,6 @@ export type LogCategory =
|
|||||||
| 'user-context'
|
| 'user-context'
|
||||||
| 'neo-user-context'
|
| 'neo-user-context'
|
||||||
| 'neo-institute-context'
|
| 'neo-institute-context'
|
||||||
| 'cc-live-transcription'
|
|
||||||
| 'search-service';
|
| 'search-service';
|
||||||
|
|
||||||
interface LogConfig {
|
interface LogConfig {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/* src/index.css */
|
/* src/index.css */
|
||||||
/* Tailwind removed — utility classes provided by tailwind-compat.css shim */
|
@tailwind base;
|
||||||
@import './tailwind-compat.css';
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
html,
|
html,
|
||||||
@ -151,10 +152,10 @@ body {
|
|||||||
|
|
||||||
.login-role-header {
|
.login-role-header {
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
color: var(--cc-action-primary) !important;
|
color: #1976d2 !important;
|
||||||
margin-bottom: 24px !important;
|
margin-bottom: 24px !important;
|
||||||
padding-bottom: 8px !important;
|
padding-bottom: 8px !important;
|
||||||
border-bottom: 2px solid var(--cc-action-primary) !important;
|
border-bottom: 2px solid #1976d2 !important;
|
||||||
width: fit-content !important;
|
width: fit-content !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,50 +6,34 @@ import { EmailLoginForm } from './EmailLoginForm';
|
|||||||
import { EmailCredentials } from '../../services/auth/authService';
|
import { EmailCredentials } from '../../services/auth/authService';
|
||||||
import { logger } from '../../debugConfig';
|
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 LoginPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, signIn, isAuthResolving } = useAuth();
|
const { user, signIn } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
logger.debug('login-page', '🔍 Login page loaded', {
|
logger.debug('login-page', '🔍 Login page loaded', {
|
||||||
hasUser: !!user,
|
hasUser: !!user
|
||||||
isAuthResolving
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthResolving && user) {
|
if (user) {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
}, [user, isAuthResolving, navigate]);
|
}, [user, navigate]);
|
||||||
|
|
||||||
const handleLogin = async (credentials: EmailCredentials) => {
|
const handleLogin = async (credentials: EmailCredentials) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await signIn(credentials.email, credentials.password);
|
await signIn(credentials.email, credentials.password);
|
||||||
|
navigate('/dashboard');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('login-page', '❌ Login failed', error);
|
logger.error('login-page', '❌ Login failed', error);
|
||||||
const message = getLoginErrorMessage(error);
|
setError(error instanceof Error ? error.message : 'Login failed');
|
||||||
setError(message);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user && !isAuthResolving) {
|
if (user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { EmailSignupForm } from './EmailSignupForm';
|
|||||||
import { EmailCredentials } from '../../services/auth/authService';
|
import { EmailCredentials } from '../../services/auth/authService';
|
||||||
import { RegistrationService } from '../../services/auth/registrationService';
|
import { RegistrationService } from '../../services/auth/registrationService';
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import MicrosoftIcon from '@mui/icons-material/Microsoft.js';
|
import MicrosoftIcon from '@mui/icons-material/Microsoft';
|
||||||
|
|
||||||
const SignupPage: React.FC = () => {
|
const SignupPage: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@ -31,7 +31,6 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
CloudUpload as UploadIcon,
|
CloudUpload as UploadIcon,
|
||||||
Upload,
|
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
FolderOpen as FolderOpenIcon,
|
FolderOpen as FolderOpenIcon,
|
||||||
Description as FileIcon,
|
Description as FileIcon,
|
||||||
@ -40,12 +39,8 @@ import {
|
|||||||
PlayArrow as ProcessIcon,
|
PlayArrow as ProcessIcon,
|
||||||
CheckCircle as SuccessIcon,
|
CheckCircle as SuccessIcon,
|
||||||
Error as ErrorIcon,
|
Error as ErrorIcon,
|
||||||
Info as InfoIcon,
|
Info as InfoIcon
|
||||||
InsertDriveFile,
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
const FolderOpen = FolderOpenIcon;
|
|
||||||
const Refresh = RefreshIcon;
|
|
||||||
const Info = InfoIcon;
|
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import {
|
import {
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
|
|||||||
@ -73,9 +73,8 @@ export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
|
|||||||
tickProgress();
|
tickProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await pdf.save();
|
|
||||||
const url = URL.createObjectURL(
|
const url = URL.createObjectURL(
|
||||||
new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })
|
new Blob([await pdf.save()], { type: 'application/pdf' })
|
||||||
);
|
);
|
||||||
tickProgress();
|
tickProgress();
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import {
|
|||||||
useTldrawUser,
|
useTldrawUser,
|
||||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
TLStore
|
TLStore,
|
||||||
|
TLStoreWithStatus
|
||||||
} from '@tldraw/tldraw';
|
} from '@tldraw/tldraw';
|
||||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
@ -14,6 +15,7 @@ import { useUser } from '../../contexts/UserContext';
|
|||||||
// Tldraw services
|
// Tldraw services
|
||||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||||
|
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
||||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||||
// Tldraw utils
|
// Tldraw utils
|
||||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||||
@ -32,7 +34,9 @@ import '../../utils/tldraw/tldraw.css';
|
|||||||
// App debug
|
// App debug
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
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 {
|
interface LoadingState {
|
||||||
status: 'ready' | 'loading' | 'error';
|
status: 'ready' | 'loading' | 'error';
|
||||||
@ -63,8 +67,9 @@ export default function SinglePlayerPage() {
|
|||||||
status: 'ready',
|
status: 'ready',
|
||||||
error: ''
|
error: ''
|
||||||
});
|
});
|
||||||
const storeRef = useRef<TLStore | null>(null);
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
const [storeReady, setStoreReady] = useState(false);
|
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||||
|
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
|
||||||
|
|
||||||
// TLDraw user preferences
|
// TLDraw user preferences
|
||||||
const tldrawUser = useTldrawUser({
|
const tldrawUser = useTldrawUser({
|
||||||
@ -80,83 +85,248 @@ export default function SinglePlayerPage() {
|
|||||||
setUserPreferences: setTldrawPreferences
|
setUserPreferences: setTldrawPreferences
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orchestrated context.node lifecycle — replaces 3 separate effects
|
// Initialize store — runs as soon as user is ready, no editor needed for store creation
|
||||||
const [canvasPhase, setCanvasPhase] = useState<
|
|
||||||
'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error'
|
|
||||||
>('idle');
|
|
||||||
const phaseError = useRef<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) {
|
||||||
let cancelled = false;
|
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 {
|
try {
|
||||||
setCanvasPhase('store-init');
|
setLoadingState({ status: 'loading', error: '' });
|
||||||
|
|
||||||
|
// 1. Create store
|
||||||
|
logger.debug('single-player-page', '🔄 Creating TLStore');
|
||||||
const newStore = localStoreService.getStore({
|
const newStore = localStoreService.getStore({
|
||||||
schema: customSchema,
|
schema: customSchema,
|
||||||
shapeUtils: allShapeUtils,
|
shapeUtils: allShapeUtils,
|
||||||
bindingUtils: allBindingUtils
|
bindingUtils: allBindingUtils
|
||||||
});
|
});
|
||||||
|
logger.debug('single-player-page', '✅ TLStore created');
|
||||||
|
|
||||||
const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
// 2. Initialize snapshot service
|
||||||
if (accessToken) snapSvc.setAccessToken(accessToken);
|
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
||||||
snapshotServiceRef.current = snapSvc;
|
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) {
|
if (context.node) {
|
||||||
setCanvasPhase('snapshot-loading');
|
const nodeStoragePath = getNodeStoragePath(context.node);
|
||||||
const path = context.node.node_storage_path;
|
if (nodeStoragePath) {
|
||||||
if (path) {
|
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(
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
path,
|
nodeStoragePath,
|
||||||
accessToken || '',
|
accessToken || '',
|
||||||
newStore,
|
newStore,
|
||||||
setLoadingState,
|
setLoadingState,
|
||||||
undefined,
|
undefined,
|
||||||
editorRef.current || 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) {
|
// 4. Set up auto-save with debouncing (only after initial load is complete)
|
||||||
newStore.dispose();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
// Clear existing timeout
|
||||||
newStore.listen(() => {
|
if (autoSaveTimeout) {
|
||||||
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
clearTimeout(autoSaveTimeout);
|
||||||
if (debounce) clearTimeout(debounce);
|
}
|
||||||
debounce = setTimeout(async () => {
|
|
||||||
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
// Debounce auto-save to prevent excessive saves
|
||||||
}, 2000);
|
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;
|
// 5. Update store state
|
||||||
setStoreReady(true);
|
setStore(newStore);
|
||||||
setCanvasPhase('ready');
|
setLoadingState({ status: 'ready', error: '' });
|
||||||
} catch (e) {
|
logger.info('single-player-page', '✅ Store initialization complete');
|
||||||
phaseError.current = e instanceof Error ? e.message : 'Init failed';
|
|
||||||
setCanvasPhase('error');
|
// 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();
|
initializeStoreAndSnapshot();
|
||||||
return () => {
|
}, [user, context.node]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Handle navigation changes — save previous snapshot, load next one.
|
// Handle initial node placement
|
||||||
// No automatic node shape placement: the canvas shows only persisted state.
|
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
|
// Inject auth and trigger initial context when token is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -315,7 +485,7 @@ export default function SinglePlayerPage() {
|
|||||||
top: `${HEADER_HEIGHT}px`,
|
top: `${HEADER_HEIGHT}px`,
|
||||||
}}>
|
}}>
|
||||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||||
{(loadingState.status === 'loading' || !storeReady) && (
|
{(loadingState.status === 'loading' || !store) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@ -343,15 +513,9 @@ export default function SinglePlayerPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
|
||||||
<ErrorBoundary fallback={
|
{store && <Tldraw
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '1rem', padding: '2rem' }}>
|
|
||||||
<p style={{ fontSize: '1.1rem' }}>Canvas failed to load.</p>
|
|
||||||
<button onClick={() => window.location.reload()} style={{ padding: '0.5rem 1.25rem', cursor: 'pointer' }}>Reload</button>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
{storeReady && storeRef.current && <Tldraw
|
|
||||||
user={tldrawUser}
|
user={tldrawUser}
|
||||||
store={storeRef.current}
|
store={store}
|
||||||
tools={singlePlayerTools}
|
tools={singlePlayerTools}
|
||||||
shapeUtils={allShapeUtils}
|
shapeUtils={allShapeUtils}
|
||||||
bindingUtils={allBindingUtils}
|
bindingUtils={allBindingUtils}
|
||||||
@ -384,17 +548,57 @@ export default function SinglePlayerPage() {
|
|||||||
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsEditorReady(true);
|
||||||
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
||||||
editorId: editor.store.id,
|
editorId: editor.store.id,
|
||||||
presentationMode,
|
presentationMode,
|
||||||
|
isEditorReady: true
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('single-player-page', '❌ Error in onMount', error);
|
logger.error('single-player-page', '❌ Error in onMount', error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>}
|
/>}
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
</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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -19,17 +19,12 @@ export class SharedStoreService {
|
|||||||
private lastSaveTime: number = Date.now();
|
private lastSaveTime: number = Date.now();
|
||||||
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
|
private autoSaveInterval: ReturnType<typeof setTimeout> | null = null;
|
||||||
private config: AutoSaveConfig;
|
private config: AutoSaveConfig;
|
||||||
private isDirty = false;
|
|
||||||
private dirtyListener: (() => void) | null = null;
|
|
||||||
|
|
||||||
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
|
constructor(private store: TLStore, config?: Partial<AutoSaveConfig>) {
|
||||||
this.config = {
|
this.config = {
|
||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
...config
|
...config
|
||||||
};
|
};
|
||||||
this.dirtyListener = store.listen(() => {
|
|
||||||
this.isDirty = true;
|
|
||||||
});
|
|
||||||
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
|
logger.debug('shared-store-service', '🏗️ Initializing SharedStoreService');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,16 +52,18 @@ export class SharedStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
|
private async checkAndSave(setLoadingState: (state: LoadingState) => void): Promise<void> {
|
||||||
if (!this.isDirty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isDirty = false;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (now - this.lastSaveTime >= this.config.saveInterval) {
|
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;
|
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 {
|
public clear(): void {
|
||||||
this.stopAutoSave();
|
this.stopAutoSave();
|
||||||
this.dirtyListener?.();
|
|
||||||
this.dirtyListener = null;
|
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
this.isDirty = false;
|
|
||||||
logger.debug('shared-store-service', '🧹 Store cleared');
|
logger.debug('shared-store-service', '🧹 Store cleared');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,9 +117,9 @@ export class NavigationSnapshotService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
loadSnapshot(editor.store, snapshotCopy as any);
|
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||||
} else {
|
} else {
|
||||||
loadSnapshot(store, snapshotCopy as any);
|
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
|
||||||
}
|
}
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -1,16 +1,3 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { expect } from 'vitest';
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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; }
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
|
import { BaseBoxShapeUtil, HTMLContainer, toDomPrecision } from '@tldraw/tldraw'
|
||||||
import { CCBaseShape } from './cc-types'
|
import { CCBaseShape } from './cc-types'
|
||||||
|
import { CC_BASE_STYLE_CONSTANTS } from './cc-styles'
|
||||||
import { logger } from '../../../debugConfig'
|
import { logger } from '../../../debugConfig'
|
||||||
|
|
||||||
export interface ToolbarItem {
|
export interface ToolbarItem {
|
||||||
@ -39,32 +40,44 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
|
|||||||
|
|
||||||
component(shape: T) {
|
component(shape: T) {
|
||||||
const {
|
const {
|
||||||
props: { w, h, isLocked, headerColor },
|
props: { w, h, isLocked },
|
||||||
} = shape
|
} = shape
|
||||||
const toolbarItems = this.getToolbarItems(shape)
|
const toolbarItems = this.getToolbarItems(shape)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HTMLContainer
|
<HTMLContainer
|
||||||
id={shape.id}
|
id={shape.id}
|
||||||
className="cc-shape-container"
|
|
||||||
style={{
|
style={{
|
||||||
width: toDomPrecision(w),
|
width: toDomPrecision(w),
|
||||||
height: toDomPrecision(h),
|
height: toDomPrecision(h),
|
||||||
'--shape-header-color': headerColor,
|
backgroundColor: shape.props.headerColor,
|
||||||
} as React.CSSProperties}
|
borderRadius: CC_BASE_STYLE_CONSTANTS.CONTAINER.borderRadius,
|
||||||
|
boxShadow: CC_BASE_STYLE_CONSTANTS.CONTAINER.boxShadow,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="cc-shape-header"
|
style={{
|
||||||
style={{ cursor: isLocked ? 'not-allowed' : 'move' }}
|
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>
|
<span style={{ color: 'white', fontWeight: 'bold' }}>{shape.props.title}</span>
|
||||||
<div className="cc-shape-toolbar">
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', pointerEvents: 'all' }}>
|
||||||
{toolbarItems.map((item) => (
|
{toolbarItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
style={{ opacity: item.isActive ? 1 : 0.7 }}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
|
logger.info('cc-base-shape-util', 'toolbar item clicked', item.id)
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -76,19 +89,46 @@ export abstract class CCBaseShapeUtil<T extends CCBaseShape> extends BaseBoxShap
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
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' }}>
|
<div style={{ pointerEvents: 'none' }}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{isLocked && <span style={{ color: 'var(--cc-text-inverse)' }}>🔒</span>}
|
{isLocked && <span style={{ color: 'white' }}>🔒</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div
|
<div
|
||||||
className="cc-shape-content"
|
style={{
|
||||||
style={{ backgroundColor: shape.props.backgroundColor }}
|
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)}
|
{this.renderContent(shape)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,16 +17,10 @@ export class CCTimetableLessonNodeShapeUtil extends CCBaseShapeUtil<CCTimetableL
|
|||||||
|
|
||||||
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
|
getDefaultProps(): CCTimetableLessonNodeShape['props'] {
|
||||||
const defaultProps = getDefaultCCTimetableLessonNodeProps()
|
const defaultProps = getDefaultCCTimetableLessonNodeProps()
|
||||||
|
const theme = NODE_THEMES[NODE_TYPE_THEMES[CCTimetableLessonNodeShapeUtil.type]]
|
||||||
return {
|
return {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
headerColor: '',
|
headerColor: theme.headerColor,
|
||||||
school_db_name: '',
|
|
||||||
school_period_id: '',
|
|
||||||
subject_class: '',
|
|
||||||
date: '',
|
|
||||||
start_time: '',
|
|
||||||
end_time: '',
|
|
||||||
period_code: '',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -543,8 +543,6 @@ export const getDefaultCCTimetableLessonNodeProps = () => ({
|
|||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
period_code: '',
|
period_code: '',
|
||||||
school_db_name: '',
|
|
||||||
school_period_id: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getDefaultCCPlannedLessonNodeProps = () => ({
|
export const getDefaultCCPlannedLessonNodeProps = () => ({
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { CCSubjectNodeShape, CCSubjectNodeShapeUtil } from './CCSubjectNodeShape
|
|||||||
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
|
import { CCTopicNodeShape, CCTopicNodeShapeUtil } from './CCTopicNodeShapeUtil'
|
||||||
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
|
import { CCTopicLessonNodeShape, CCTopicLessonNodeShapeUtil } from './CCTopicLessonNodeShapeUtil'
|
||||||
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
|
import { CCLearningStatementNodeShape, CCLearningStatementNodeShapeUtil } from './CCLearningStatementNodeShapeUtil'
|
||||||
|
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
|
||||||
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
|
import { CCSchoolTimetableNodeShape, CCSchoolTimetableNodeShapeUtil } from './CCSchoolTimetableNodeShapeUtil'
|
||||||
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
|
import { CCAcademicYearNodeShape, CCAcademicYearNodeShapeUtil } from './CCAcademicYearNodeShapeUtil'
|
||||||
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
|
import { CCAcademicTermNodeShape, CCAcademicTermNodeShapeUtil } from './CCAcademicTermNodeShapeUtil'
|
||||||
@ -30,11 +31,12 @@ import { CCAcademicDayNodeShape, CCAcademicDayNodeShapeUtil } from './CCAcademic
|
|||||||
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
|
import { CCAcademicPeriodNodeShape, CCAcademicPeriodNodeShapeUtil } from './CCAcademicPeriodNodeShapeUtil'
|
||||||
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
|
import { CCRegistrationPeriodNodeShape, CCRegistrationPeriodNodeShapeUtil } from './CCRegistrationPeriodNodeShapeUtil'
|
||||||
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
|
import { CCTeacherTimetableNodeShape, CCTeacherTimetableNodeShapeUtil } from './CCTeacherTimetableNodeShapeUtil'
|
||||||
|
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
||||||
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
|
import { CCPlannedLessonNodeShape, CCPlannedLessonNodeShapeUtil } from './CCPlannedLessonNodeShapeUtil'
|
||||||
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
|
import { CCDepartmentStructureNodeShape, CCDepartmentStructureNodeShapeUtil } from './CCDepartmentStructureNodeShapeUtil'
|
||||||
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
|
import { CCUserTeacherTimetableNodeShape, CCUserTeacherTimetableNodeShapeUtil } from './CCUserTeacherTimetableNodeShapeUtil'
|
||||||
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCUserTimetableLessonNodeShapeUtil'
|
import { CCTimetableLessonNodeShape, CCTimetableLessonNodeShapeUtil } from './CCTimetableLessonNodeShapeUtil'
|
||||||
import { CCScienceLabNodeShape, CCScienceLabNodeShapeUtil } from './CCScienceLabNodeShapeUtil'
|
|
||||||
// Create a const object with all node types
|
// Create a const object with all node types
|
||||||
export const NODE_SHAPE_TYPES = {
|
export const NODE_SHAPE_TYPES = {
|
||||||
USER: CCUserNodeShapeUtil.type,
|
USER: CCUserNodeShapeUtil.type,
|
||||||
@ -116,7 +118,8 @@ export type AllNodeShapes =
|
|||||||
| CCTimetableLessonNodeShape
|
| CCTimetableLessonNodeShape
|
||||||
| CCPlannedLessonNodeShape
|
| CCPlannedLessonNodeShape
|
||||||
| CCDepartmentStructureNodeShape
|
| CCDepartmentStructureNodeShape
|
||||||
| CCUserTeacherTimetableNodeShape;
|
| CCUserTeacherTimetableNodeShape
|
||||||
|
| CCTimetableLessonNodeShape;
|
||||||
|
|
||||||
// Export all shape utils in an object for easy access
|
// Export all shape utils in an object for easy access
|
||||||
export const ShapeUtils = {
|
export const ShapeUtils = {
|
||||||
@ -156,6 +159,7 @@ export const ShapeUtils = {
|
|||||||
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
|
[CCPlannedLessonNodeShapeUtil.type]: CCPlannedLessonNodeShapeUtil,
|
||||||
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
|
[CCDepartmentStructureNodeShapeUtil.type]: CCDepartmentStructureNodeShapeUtil,
|
||||||
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
|
[CCUserTeacherTimetableNodeShapeUtil.type]: CCUserTeacherTimetableNodeShapeUtil,
|
||||||
|
[CCTimetableLessonNodeShapeUtil.type]: CCTimetableLessonNodeShapeUtil,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Add a type guard to check if a shape is a valid node shape
|
// Add a type guard to check if a shape is a valid node shape
|
||||||
|
|||||||
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -6,7 +6,6 @@ import { TranscriptionManager } from '../cc-transcription/TranscriptionManager';
|
|||||||
import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props';
|
import { ccShapeProps, getDefaultCCLiveTranscriptionProps } from '../cc-props';
|
||||||
import { ccShapeMigrations } from '../cc-migrations';
|
import { ccShapeMigrations } from '../cc-migrations';
|
||||||
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
|
import { CC_BASE_STYLE_CONSTANTS } from '../cc-styles';
|
||||||
import { logger } from '../../../../debugConfig';
|
|
||||||
|
|
||||||
export interface TranscriptionSegment {
|
export interface TranscriptionSegment {
|
||||||
id: string
|
id: string
|
||||||
@ -84,7 +83,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
height: '48px',
|
height: '48px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
backgroundColor: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)',
|
backgroundColor: isRecording ? '#f44336' : '#4CAF50',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@ -106,7 +105,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: isRecording ? 'var(--cc-recording-active)' : 'var(--cc-recording-idle)'
|
color: isRecording ? '#f44336' : '#4CAF50'
|
||||||
}}>
|
}}>
|
||||||
{isRecording ? 'Recording...' : 'Ready'}
|
{isRecording ? 'Recording...' : 'Ready'}
|
||||||
</div>
|
</div>
|
||||||
@ -208,11 +207,11 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toggleRecording(shape: CCLiveTranscriptionShape) {
|
private toggleRecording(shape: CCLiveTranscriptionShape) {
|
||||||
logger.debug('cc-live-transcription', '🎤 Toggle recording clicked');
|
console.log('🎤 Toggle recording clicked');
|
||||||
const { id } = shape;
|
const { id } = shape;
|
||||||
const { isRecording } = shape.props;
|
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
|
// When starting new recording, preserve existing props but reset segments
|
||||||
const newProps = !isRecording ? {
|
const newProps = !isRecording ? {
|
||||||
@ -233,13 +232,13 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
});
|
});
|
||||||
|
|
||||||
const manager = TranscriptionManager.getManager(this.editor);
|
const manager = TranscriptionManager.getManager(this.editor);
|
||||||
logger.debug('cc-live-transcription', 'Got transcription manager');
|
console.log('Got transcription manager');
|
||||||
|
|
||||||
if (!isRecording) {
|
if (!isRecording) {
|
||||||
logger.debug('cc-live-transcription', 'Starting transcription...');
|
console.log('Starting transcription...');
|
||||||
manager.startTranscription(id);
|
manager.startTranscription(id);
|
||||||
} else {
|
} else {
|
||||||
logger.debug('cc-live-transcription', 'Stopping transcription...');
|
console.log('Stopping transcription...');
|
||||||
manager.stopTranscription();
|
manager.stopTranscription();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -250,10 +249,10 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
isConfirmed: boolean,
|
isConfirmed: boolean,
|
||||||
metadata?: { start: string | number, end: string | number }
|
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);
|
const shape = this.editor.getShape<CCLiveTranscriptionShape>(id);
|
||||||
if (!shape) {
|
if (!shape) {
|
||||||
logger.warn('cc-live-transcription', '❌ Shape not found for updating text', id);
|
console.warn('❌ Shape not found for updating text:', id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,7 +308,7 @@ export class CCLiveTranscriptionShapeUtil extends CCBaseShapeUtil<CCLiveTranscri
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('cc-live-transcription', '✅ Text updated');
|
console.log('✅ Text updated');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,8 +27,8 @@ export class TranscriptionManager {
|
|||||||
this.transcriptionService = new TranscriptionService();
|
this.transcriptionService = new TranscriptionService();
|
||||||
|
|
||||||
// Set up callback for transcription updates
|
// Set up callback for transcription updates
|
||||||
this.transcriptionService.setServerSegmentsCallback((segments: Array<{ text: string; start: number; end: number }>, isLastLive: boolean) => {
|
this.transcriptionService.setTranscriptionCallback((text: string, isFinal: boolean, metadata: { start: number, end: number }) => {
|
||||||
console.log('📝 Transcription update received:', { segments: segments.length, isLastLive });
|
console.log('📝 Transcription update received:', { text, metadata });
|
||||||
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
|
const util = this.editor.getShapeUtil<CCLiveTranscriptionShapeUtil>('cc-live-transcription');
|
||||||
if (!util) {
|
if (!util) {
|
||||||
console.warn('❌ Shape util not found');
|
console.warn('❌ Shape util not found');
|
||||||
@ -36,10 +36,6 @@ export class TranscriptionManager {
|
|||||||
}
|
}
|
||||||
console.log('Found transcription util:', !!util);
|
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)
|
// Check if text is stable (same output multiple times)
|
||||||
const isStable = text === this.lastText;
|
const isStable = text === this.lastText;
|
||||||
if (isStable) {
|
if (isStable) {
|
||||||
|
|||||||
@ -18,40 +18,40 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
minWidth: '200px',
|
minWidth: '200px',
|
||||||
backgroundColor: 'var(--cc-navy-50)',
|
backgroundColor: '#f5f5f5',
|
||||||
borderLeft: '1px solid var(--cc-border)',
|
borderLeft: '1px solid #ddd',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
margin: '0 0 10px 0',
|
margin: '0 0 10px 0',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: 'var(--cc-text-primary)',
|
color: '#333',
|
||||||
},
|
},
|
||||||
line: {
|
line: {
|
||||||
padding: '5px',
|
padding: '5px',
|
||||||
marginBottom: '5px',
|
marginBottom: '5px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: 'var(--cc-text-primary)',
|
color: '#333',
|
||||||
backgroundColor: 'var(--cc-bg-canvas)',
|
backgroundColor: '#fff',
|
||||||
border: '1px solid var(--cc-border-strong)',
|
border: '1px solid #eee',
|
||||||
},
|
},
|
||||||
activeLine: {
|
activeLine: {
|
||||||
backgroundColor: 'var(--cc-navy-50)',
|
backgroundColor: '#e3f2fd',
|
||||||
border: '1px solid var(--cc-action-primary)',
|
border: '1px solid #2196f3',
|
||||||
color: 'var(--cc-action-primary)',
|
color: '#1565c0',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.12)',
|
||||||
transform: 'scale(1.02)',
|
transform: 'scale(1.02)',
|
||||||
transition: 'all 0.2s ease-in-out',
|
transition: 'all 0.2s ease-in-out',
|
||||||
},
|
},
|
||||||
timestamp: {
|
timestamp: {
|
||||||
color: 'var(--cc-text-tertiary)',
|
color: '#666',
|
||||||
marginRight: '8px',
|
marginRight: '8px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
loading: {
|
loading: {
|
||||||
color: 'var(--cc-text-muted)',
|
color: '#666',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -64,14 +64,14 @@ export const CC_YOUTUBE_EMBED_STYLE_CONSTANTS = {
|
|||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
backgroundColor: 'var(--cc-navy-50)',
|
backgroundColor: '#f0f0f0',
|
||||||
border: '1px solid var(--cc-border)',
|
border: '1px solid #ddd',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: 'var(--cc-text-primary)',
|
color: '#333',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: 'var(--cc-border-strong)',
|
backgroundColor: '#e0e0e0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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("@tldraw/tldraw/tldraw.css");
|
||||||
@import url('./cc-design-system.css');
|
|
||||||
|
|
||||||
.tl-container {
|
|
||||||
--color-panel: var(--cc-bg-panel);
|
|
||||||
--color-text: var(--cc-text-primary);
|
|
||||||
--color-muted: var(--cc-border);
|
|
||||||
--color-muted-1: var(--cc-border-strong);
|
|
||||||
--color-background: var(--cc-bg-canvas);
|
|
||||||
--color-selected: var(--cc-selected);
|
|
||||||
--color-accent: var(--cc-action-primary);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,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 { useLocation } from 'react-router-dom';
|
||||||
import { TldrawUiButton } from '@tldraw/tldraw';
|
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 { CCShapesPanel } from './CCShapesPanel';
|
||||||
import { CCSlidesPanel } from './CCSlidesPanel';
|
import { CCSlidesPanel } from './CCSlidesPanel';
|
||||||
import { CCFilesPanel } from './CCFilesPanel';
|
import { CCFilesPanel } from './CCFilesPanel';
|
||||||
@ -8,9 +33,9 @@ import { CCCabinetsPanel } from './CCCabinetsPanel';
|
|||||||
import { CCYoutubePanel } from './CCYoutubePanel';
|
import { CCYoutubePanel } from './CCYoutubePanel';
|
||||||
import { CCGraphPanel } from './CCGraphPanel';
|
import { CCGraphPanel } from './CCGraphPanel';
|
||||||
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
||||||
import { CCSearchPanel } from './CCSearchPanel';
|
import { CCSearchPanel } from './CCSearchPanel'
|
||||||
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
|
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel'
|
||||||
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
|
import { CCTranscriptionPanel } from './CCTranscriptionPanel'
|
||||||
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
import { PANEL_DIMENSIONS, Z_INDICES } from './panel-styles';
|
||||||
import './panel.css';
|
import './panel.css';
|
||||||
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
|
// import { CCNavigationPanel } from './navigation/CCNavigationPanel';
|
||||||
@ -53,102 +78,45 @@ interface BasePanelProps {
|
|||||||
onMenuOpenChange?: (open: boolean) => void;
|
onMenuOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIconSvg(panelId: PanelType, size = '1.25rem') {
|
const PanelTypeButton = styled(Button)(() => ({
|
||||||
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 };
|
textTransform: 'none',
|
||||||
switch (panelId) {
|
padding: '6px 12px',
|
||||||
case 'cabinets':
|
gap: '8px',
|
||||||
return (
|
backgroundColor: 'var(--color-panel)',
|
||||||
<svg {...common}>
|
color: 'var(--color-text)',
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
border: '1px solid transparent',
|
||||||
<polyline points="9 22 9 12 15 12 15 22" />
|
transition: 'border-color 200ms ease',
|
||||||
</svg>
|
justifyContent: 'space-between',
|
||||||
);
|
minWidth: '200px',
|
||||||
case 'transcription':
|
'&:hover': {
|
||||||
return (
|
backgroundColor: 'var(--color-panel)',
|
||||||
<svg {...common}>
|
borderColor: 'var(--color-text)',
|
||||||
<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" />
|
'& .MuiSvgIcon-root': {
|
||||||
<line x1="12" y1="19" x2="12" y2="23" />
|
fontSize: '1.25rem',
|
||||||
<line x1="8" y1="23" x2="6" y2="21" />
|
color: 'inherit',
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}));
|
||||||
|
|
||||||
function isDarkMode() {
|
const StyledMenuItem = styled(MenuItem)(() => ({
|
||||||
if (typeof window === 'undefined') return false;
|
gap: '8px',
|
||||||
return document.documentElement.dataset.colorMode === 'dark' ||
|
padding: '8px 16px',
|
||||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
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> = ({
|
export const BasePanel: React.FC<BasePanelProps> = ({
|
||||||
initialPanelType = 'files',
|
initialPanelType = 'files',
|
||||||
@ -162,30 +130,36 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { tldrawPreferences } = useTLDraw();
|
const { tldrawPreferences } = useTLDraw();
|
||||||
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Create a dynamic theme based on TLDraw preferences
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
const theme = useMemo(() => {
|
||||||
const handler = () => setPrefersDarkMode(isDarkMode());
|
let mode: 'light' | 'dark';
|
||||||
mq.addEventListener?.('change', handler);
|
|
||||||
return () => mq.removeEventListener?.('change', handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const colorMode = (tldrawPreferences?.colorScheme === 'system' ? prefersDarkMode : tldrawPreferences?.colorScheme === 'dark')
|
if (tldrawPreferences?.colorScheme === 'system') {
|
||||||
? 'dark'
|
mode = prefersDarkMode ? 'dark' : 'light';
|
||||||
: 'light';
|
} else {
|
||||||
|
mode = tldrawPreferences?.colorScheme === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
return createTheme({
|
||||||
|
palette: {
|
||||||
|
mode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [tldrawPreferences?.colorScheme, prefersDarkMode]);
|
||||||
|
|
||||||
const isExamMarkerRoute = location.pathname === '/exam-marker';
|
const isExamMarkerRoute = location.pathname === '/exam-marker';
|
||||||
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
|
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
|
||||||
|
|
||||||
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(
|
const [currentPanelType, setCurrentPanelType] = React.useState<PanelType>(
|
||||||
isExamMarkerRoute ? 'exam-marker' : initialPanelType
|
isExamMarkerRoute ? 'exam-marker' : initialPanelType
|
||||||
);
|
);
|
||||||
|
|
||||||
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
// Use controlled state if provided, otherwise use internal state
|
||||||
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
const [internalIsExpanded, setInternalIsExpanded] = React.useState(true);
|
||||||
|
const [internalIsPinned, setInternalIsPinned] = React.useState(true);
|
||||||
|
|
||||||
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
const isExpanded = controlledIsExpanded ?? internalIsExpanded;
|
||||||
const isPinned = controlledIsPinned ?? internalIsPinned;
|
const isPinned = controlledIsPinned ?? internalIsPinned;
|
||||||
@ -204,24 +178,87 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
|
const dimensions = PANEL_DIMENSIONS[currentPanelType as keyof typeof PANEL_DIMENSIONS];
|
||||||
|
|
||||||
|
// Handle click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
// Don't close if pinned
|
||||||
if (isPinned) return;
|
if (isPinned) return;
|
||||||
|
|
||||||
|
// Check if click is outside panel
|
||||||
const isClickOutside = panelRef.current && !panelRef.current.contains(event.target as Node);
|
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 target = event.target as HTMLElement;
|
||||||
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
|
const isPanelElement = target.closest('.panel-root, .panel-handle, .tlui-button');
|
||||||
|
|
||||||
if (isClickOutside && !isPanelElement) {
|
if (isClickOutside && !isPanelElement) {
|
||||||
handleExpandedChange(false);
|
handleExpandedChange(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [isExpanded, isPinned]);
|
}, [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 = () => {
|
const renderCurrentPanel = () => {
|
||||||
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
|
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
|
||||||
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
|
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
|
||||||
@ -251,9 +288,16 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMenu = (open: boolean) => {
|
// Handle menu button click
|
||||||
setMenuOpen(open);
|
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
onMenuOpenChange(open);
|
setMenuAnchorEl(event.currentTarget);
|
||||||
|
onMenuOpenChange(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle menu close
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setMenuAnchorEl(null);
|
||||||
|
onMenuOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -283,61 +327,55 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<button
|
<ThemeProvider theme={theme}>
|
||||||
type="button"
|
<PanelTypeButton
|
||||||
className="cc-btn cc-btn--secondary cc-panel-selector"
|
onClick={handleMenuClick}
|
||||||
onClick={() => toggleMenu(!menuOpen)}
|
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}
|
{availablePanels.find(p => p.id === currentPanelType)?.label}
|
||||||
</span>
|
</PanelTypeButton>
|
||||||
<span className="cc-btn-end-icon">
|
|
||||||
<svg width="1.25rem" height="1.25rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="6 9 12 15 18 9" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{menuOpen && (
|
<Menu
|
||||||
<div className="cc-menu panel-type-menu" role="menu">
|
anchorEl={menuAnchorEl}
|
||||||
|
open={isMenuOpen}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
PaperProps={{
|
||||||
|
elevation: 8,
|
||||||
|
sx: {
|
||||||
|
border: '1px solid var(--color-divider)',
|
||||||
|
boxShadow: 'var(--shadow-popup)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{[...availablePanels]
|
{[...availablePanels]
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map(type => (
|
.map(type => (
|
||||||
<button
|
<StyledMenuItem
|
||||||
key={type.id}
|
key={type.id}
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={`cc-menu-item ${type.id === currentPanelType ? 'cc-menu-item--active' : ''}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentPanelType(type.id as PanelType);
|
setCurrentPanelType(type.id as PanelType);
|
||||||
toggleMenu(false);
|
handleMenuClose();
|
||||||
}}
|
}}
|
||||||
|
selected={currentPanelType === type.id}
|
||||||
>
|
>
|
||||||
<span className="cc-menu-item-icon">
|
<ListItemIcon>
|
||||||
{getIconSvg(type.id as PanelType)}
|
{getIconForPanel(type.id as PanelType)}
|
||||||
</span>
|
</ListItemIcon>
|
||||||
<span className="cc-menu-item-text">
|
<ListItemText
|
||||||
<span className="cc-menu-item-primary">{type.label}</span>
|
primary={type.label}
|
||||||
<span className="cc-menu-item-secondary">
|
secondary={getDescriptionForPanel(type.id as PanelType)}
|
||||||
{type.id === 'cabinets' && 'Manage file cabinets'}
|
primaryTypographyProps={{
|
||||||
{type.id === 'transcription' && 'Record and transcribe lessons'}
|
sx: { color: 'var(--color-text)' }
|
||||||
{type.id === 'cc-shapes' && 'Add shapes and elements to your canvas'}
|
}}
|
||||||
{type.id === 'slides' && 'Manage presentation slides'}
|
secondaryTypographyProps={{
|
||||||
{type.id === 'youtube' && 'Embed YouTube videos'}
|
sx: { color: 'var(--color-text-secondary)' }
|
||||||
{type.id === 'graph' && 'View and manage graph connections'}
|
}}
|
||||||
{type.id === 'search' && 'Search through your content'}
|
/>
|
||||||
{type.id === 'navigation' && 'Navigate through different contexts'}
|
</StyledMenuItem>
|
||||||
{type.id === 'node-snapshot' && 'Manage node snapshots'}
|
|
||||||
{type.id === 'exam-marker' && 'Mark and grade exams'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Menu>
|
||||||
)}
|
</ThemeProvider>
|
||||||
|
|
||||||
<div className="panel-header-actions">
|
<div className="panel-header-actions">
|
||||||
<TldrawUiButton
|
<TldrawUiButton
|
||||||
@ -345,36 +383,16 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
onClick={handlePinToggle}
|
onClick={handlePinToggle}
|
||||||
className="pin-button"
|
className="pin-button"
|
||||||
>
|
>
|
||||||
<span className="cc-icon-button">
|
{isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
|
||||||
<svg
|
|
||||||
width="1.25rem"
|
|
||||||
height="1.25rem"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
{isPinned ? (
|
|
||||||
<>
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M7.5 7.5l9 9" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</TldrawUiButton>
|
</TldrawUiButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
<div className="panel-content">
|
<div className="panel-content">
|
||||||
{renderCurrentPanel()}
|
{renderCurrentPanel()}
|
||||||
</div>
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -200,7 +200,7 @@ export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
|
|||||||
|
|
||||||
const pdfBytes = await newPdf.save();
|
const pdfBytes = await newPdf.save();
|
||||||
const url = URL.createObjectURL(
|
const url = URL.createObjectURL(
|
||||||
new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' })
|
new Blob([pdfBytes], { type: 'application/pdf' })
|
||||||
);
|
);
|
||||||
tickProgress();
|
tickProgress();
|
||||||
|
|
||||||
@ -297,7 +297,7 @@ export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
|
|||||||
|
|
||||||
// Save the combined PDF
|
// Save the combined PDF
|
||||||
const pdfBytes = await newPdf.save();
|
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');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = currentPdf.name;
|
a.download = currentPdf.name;
|
||||||
|
|||||||
@ -346,10 +346,9 @@ export const CCFilesPanelEnhanced: React.FC = () => {
|
|||||||
ref={dirInputRef}
|
ref={dirInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
webkitdirectory=""
|
||||||
multiple
|
multiple
|
||||||
onChange={handleFallbackDirectorySelect}
|
onChange={handleFallbackDirectorySelect}
|
||||||
// @ts-ignore WebKit directory picker attribute for folder upload
|
|
||||||
webkitdirectory=""
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip title={isDirectoryPickerSupported() ? "Uses modern directory picker" : "Uses fallback method"}>
|
<Tooltip title={isDirectoryPickerSupported() ? "Uses modern directory picker" : "Uses fallback method"}>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, createContext, useContext, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
|
Box, IconButton, CircularProgress, Collapse, Typography, Tooltip,
|
||||||
ToggleButtonGroup, ToggleButton,
|
ToggleButtonGroup, ToggleButton,
|
||||||
@ -518,7 +518,7 @@ export function CCGraphNavPanel() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setTree(data.tree);
|
setTree(data.tree);
|
||||||
} catch (err) {
|
} 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');
|
setError('Failed to load navigation tree');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -650,12 +650,12 @@ export function CCGraphNavPanel() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCalendarWizardComplete = useCallback(() => {
|
const handleCalendarWizardComplete = useCallback(() => {
|
||||||
logger.info('navigation-panel', 'School calendar setup complete');
|
logger.info('graph-nav-panel', 'School calendar setup complete');
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}, [refreshAll]);
|
}, [refreshAll]);
|
||||||
|
|
||||||
const handleTimetableWizardComplete = useCallback((timetableId: string) => {
|
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();
|
||||||
}, [refreshAll]);
|
}, [refreshAll]);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { Box, Typography, styled, Button, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
|
import { Box, Typography, styled, Button, ThemeProvider, createTheme, useMediaQuery } from '@mui/material';
|
||||||
import Save from '@mui/icons-material/Save';
|
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 { useEditor, useToasts, loadSnapshot } from '@tldraw/tldraw';
|
||||||
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
import { useNavigationStore } from '../../../../../../stores/navigationStore';
|
||||||
import { useAuth } from '../../../../../../contexts/AuthContext';
|
import { useAuth } from '../../../../../../contexts/AuthContext';
|
||||||
|
|||||||
@ -269,65 +269,3 @@
|
|||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CC Base Shape Styles */
|
|
||||||
.cc-shape-container {
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 4px var(--color-muted-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-shape-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
cursor: not-allowed;
|
|
||||||
pointer-events: all;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
background: var(--shape-header-color, var(--color-muted));
|
|
||||||
padding: 8px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-shape-header .shape-title {
|
|
||||||
color: var(--cc-text-inverse);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-shape-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-shape-toolbar button {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: white;
|
|
||||||
opacity: 0.7;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: all;
|
|
||||||
font-size: 16px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
z-index: 100;
|
|
||||||
user-select: none;
|
|
||||||
position: relative;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-shape-content {
|
|
||||||
position: absolute;
|
|
||||||
top: 32px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
@ -117,20 +117,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: mode === 'development',
|
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: {
|
resolve: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user